mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Attach verified access context to eligible plugin MCP calls (#41005)
## What changed - Fetch ChatGPT account access and attach `cyber_trusted_access` under `openai/entitlementContext` when an installed or selected plugin explicitly requests it from a local, read-only stdio tool with no arguments. - Reject caller-supplied entitlement context on direct MCP calls, and fall back to an `unknown` status when access cannot be verified or the account changes during the lookup. - Count entitlement lookup time against the MCP tool-call timeout. ## Testing - Cover eligible calls plus unowned, remote, HTTP, mutable, argument-bearing, API-key, undeclared, direct, and account-switch cases in core and app-server integration tests. GitOrigin-RevId: 303b61585ef4530276c373d914a2d0ccef1f4ac2
This commit is contained in:
@@ -97,7 +97,7 @@ impl AppsRequestProcessor {
|
||||
codex_apps_tools_cache_key: cache_key.clone(),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: auth.clone(),
|
||||
codex_apps_auth_manager,
|
||||
auth_manager: codex_apps_auth_manager,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::MockResponsesConfig;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::CapabilityRootLocation;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::McpServerToolCallResponse;
|
||||
use codex_app_server_protocol::SelectedCapabilityRoot;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use wiremock::Mock;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AccessCall {
|
||||
Installed,
|
||||
Selected,
|
||||
Unowned,
|
||||
ApiKey,
|
||||
Unlisted,
|
||||
}
|
||||
|
||||
#[test_case(AccessCall::Installed; "installed direct call")]
|
||||
#[test_case(AccessCall::Selected; "selected model and direct calls")]
|
||||
#[test_case(AccessCall::Unowned; "unowned direct call")]
|
||||
#[test_case(AccessCall::ApiKey; "api key direct call")]
|
||||
#[test_case(AccessCall::Unlisted; "unlisted direct call")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn daybreak_access_respects_plugin_provenance(case: AccessCall) -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let home = TempDir::new()?;
|
||||
let selected_package = TempDir::new()?;
|
||||
let selected = matches!(case, AccessCall::Selected);
|
||||
let plugin_root = if selected {
|
||||
selected_package.path().to_path_buf()
|
||||
} else {
|
||||
home.path().join("plugins/cache/test/sample/local")
|
||||
};
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)?;
|
||||
let mut mcp_config = json!({
|
||||
"command":stdio_server_bin()?, "cwd":plugin_root, "environment_id":"local",
|
||||
"default_tools_approval_mode":"approve", "env":{"MCP_TEST_DAYBREAK_READ_ONLY":"true"}
|
||||
});
|
||||
if matches!(case, AccessCall::Unlisted) {
|
||||
mcp_config["env"] = json!({});
|
||||
}
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
serde_json::to_vec(&json!({"mcpServers":{"sample":mcp_config}}))?,
|
||||
)?;
|
||||
let extra_config = match case {
|
||||
AccessCall::Installed | AccessCall::ApiKey | AccessCall::Unlisted => {
|
||||
"[plugins.\"sample@test\"]\nenabled = true\n".to_string()
|
||||
}
|
||||
AccessCall::Selected => String::new(),
|
||||
AccessCall::Unowned => toml::to_string(&json!({"mcp_servers":{"sample":mcp_config}}))?,
|
||||
};
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.enable_feature(Feature::Plugins)
|
||||
.disable_feature(Feature::Apps)
|
||||
.with_root_config(&format!(
|
||||
"chatgpt_base_url = \"{}\"\ncli_auth_credentials_store = \"file\"",
|
||||
server.uri()
|
||||
))
|
||||
.with_extra_config(&extra_config)
|
||||
.write(home.path())?;
|
||||
let api_key = matches!(case, AccessCall::ApiKey).then_some("daybreak-test-api-key");
|
||||
if api_key.is_none() {
|
||||
write_chatgpt_auth(
|
||||
home.path(),
|
||||
ChatGptAuthFixture::new("daybreak-test-token").account_id("daybreak-account"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
}
|
||||
let mut app = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", api_key)])
|
||||
// Daybreak metadata is local-host-only, even when the test runner provides a remote executor.
|
||||
.without_auto_env()
|
||||
.build_initialized_with_timeout(std::time::Duration::from_secs(/*secs*/ 30))
|
||||
.await?;
|
||||
let selected_capability_roots = if selected {
|
||||
Some(vec![SelectedCapabilityRoot {
|
||||
id: "selected-daybreak".to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "local".to_string(),
|
||||
path: PathUri::from_host_native_path(&plugin_root)?,
|
||||
},
|
||||
}])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let response: ThreadStartResponse = app
|
||||
.request(|request_id| ClientRequest::ThreadStart {
|
||||
request_id,
|
||||
params: ThreadStartParams {
|
||||
selected_capability_roots,
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let thread = response.thread;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/accounts/verified_access"))
|
||||
.and(header("authorization", "Bearer daybreak-test-token"))
|
||||
.and(header("chatgpt-account-id", "daybreak-account"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
|
||||
"programs": [{"program":"cyber", "state":"active", "grants":[
|
||||
{"level":"tac1", "source":"individual"}
|
||||
]}]
|
||||
})))
|
||||
.expect(match case {
|
||||
AccessCall::Selected => 1,
|
||||
AccessCall::Installed
|
||||
| AccessCall::Unowned
|
||||
| AccessCall::ApiKey
|
||||
| AccessCall::Unlisted => 0,
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
let expected = json!({
|
||||
"schemaVersion":1, "entitlements":{"cyber_trusted_access":{
|
||||
"schemaVersion":1,"status":"granted","grants":[{"level":"tac1","source":"user"}],"stale":false
|
||||
}}
|
||||
});
|
||||
let mut results = Vec::new();
|
||||
// Selected capabilities are materialized by the first turn, as in the executor MCP tests.
|
||||
if selected {
|
||||
let _responses = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_tool_search_call(
|
||||
"search",
|
||||
&json!({"query":"get_codex_security_daybreak_access"}),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_function_call_with_namespace(
|
||||
"daybreak-call",
|
||||
"mcp__sample",
|
||||
"get_codex_security_daybreak_access",
|
||||
"{}",
|
||||
),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("done", "done"),
|
||||
responses::ev_completed("resp-3"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
app.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "Check Daybreak access".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
loop {
|
||||
let event: ItemCompletedNotification = app.read_notification("item/completed").await?;
|
||||
if let ThreadItem::McpToolCall {
|
||||
result, plugin_id, ..
|
||||
} = event.item
|
||||
{
|
||||
assert_eq!(plugin_id.as_deref(), Some("selected-daybreak"));
|
||||
results.push((
|
||||
result
|
||||
.expect("successful model call")
|
||||
.structured_content
|
||||
.expect("echoed metadata"),
|
||||
Some(expected),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let response: McpServerToolCallResponse = app
|
||||
.request(|request_id| ClientRequest::McpServerToolCall {
|
||||
request_id,
|
||||
params: McpServerToolCallParams {
|
||||
thread_id: thread.id.clone(),
|
||||
server: "sample".to_string(),
|
||||
tool: "get_daybreak_access".to_string(),
|
||||
arguments: Some(json!({})),
|
||||
meta: Some(json!({
|
||||
"source":"direct-test",
|
||||
"openai/entitlementContext":{"untrusted":"caller-supplied"}
|
||||
})),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let direct_metadata = response.structured_content.expect("echoed metadata");
|
||||
assert_eq!(direct_metadata["source"], "direct-test");
|
||||
results.push((direct_metadata, None));
|
||||
for (metadata, expected) in results {
|
||||
assert_eq!(metadata.get("openai/entitlementContext"), expected.as_ref());
|
||||
assert_eq!(metadata["threadId"], thread.id);
|
||||
assert!(!metadata.to_string().contains("daybreak-test-token"));
|
||||
}
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod connection_handling_websocket_unix;
|
||||
mod curated_mcp_sync;
|
||||
mod current_time;
|
||||
mod cyber_access_program;
|
||||
mod daybreak_access;
|
||||
mod dynamic_tools;
|
||||
mod environment_add;
|
||||
mod environment_info;
|
||||
|
||||
@@ -170,7 +170,7 @@ impl fmt::Debug for McpBinding {
|
||||
/// one [`McpBinding`].
|
||||
#[derive(Clone)]
|
||||
pub struct PreparedMcpCall {
|
||||
_connections: Arc<McpConnectionSet>,
|
||||
connections: Arc<McpConnectionSet>,
|
||||
client: Arc<ManagedClient>,
|
||||
config: Arc<McpConfig>,
|
||||
catalog_revision: u64,
|
||||
@@ -201,7 +201,7 @@ impl PreparedMcpCall {
|
||||
let server_name = tool_info.server_name.clone();
|
||||
config.permission_profile_for_server(&server_name)?;
|
||||
Some(Self {
|
||||
_connections: connections,
|
||||
connections,
|
||||
client,
|
||||
config,
|
||||
catalog_revision,
|
||||
@@ -321,10 +321,40 @@ impl PreparedMcpCall {
|
||||
));
|
||||
}
|
||||
let (arguments, meta) = prepare().await?;
|
||||
let timeout_deadline =
|
||||
effective_timeout.map(|timeout| tokio::time::Instant::now() + timeout);
|
||||
let add_trusted_access_context = self.connections.add_trusted_access_context(
|
||||
&self.tool_info,
|
||||
&self.server_metadata,
|
||||
arguments.as_ref(),
|
||||
meta,
|
||||
);
|
||||
let meta = match effective_timeout.zip(timeout_deadline) {
|
||||
Some((timeout, deadline)) => {
|
||||
tokio::time::timeout_at(deadline, add_trusted_access_context)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("timed out awaiting tools/call after {timeout:.0?}")
|
||||
})?
|
||||
}
|
||||
None => add_trusted_access_context.await,
|
||||
};
|
||||
let remaining_timeout = match effective_timeout.zip(timeout_deadline) {
|
||||
Some((timeout, deadline)) => {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"timed out awaiting tools/call after {timeout:.0?}"
|
||||
));
|
||||
}
|
||||
Some(remaining)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let result = self
|
||||
.client
|
||||
.client
|
||||
.call_tool(tool_name.clone(), arguments, meta, effective_timeout)
|
||||
.call_tool(tool_name.clone(), arguments, meta, remaining_timeout)
|
||||
.await
|
||||
.with_context(|| format!("tool call failed for `{}/{tool_name}`", self.server_name))?;
|
||||
drop(current_revision);
|
||||
|
||||
@@ -52,6 +52,8 @@ use crate::tool_catalog_cache::McpToolCatalogCacheContext;
|
||||
use crate::tools::ToolFilter;
|
||||
use crate::tools::ToolInfo;
|
||||
use crate::tools::filter_tools;
|
||||
use crate::trusted_access::ENTITLEMENT_CONTEXT_KEY;
|
||||
use crate::trusted_access::TrustedAccessContext;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
@@ -192,6 +194,7 @@ pub(crate) struct McpConnectionSet {
|
||||
prefix_mcp_tool_names: bool,
|
||||
non_prefixed_mcp_tool_servers: Vec<String>,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
pub(crate) trusted_access: Option<TrustedAccessContext>,
|
||||
}
|
||||
|
||||
impl McpConnectionSet {
|
||||
@@ -203,6 +206,7 @@ impl McpConnectionSet {
|
||||
input: McpRuntimeInput,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
) -> Self {
|
||||
let trusted_access = TrustedAccessContext::from_runtime(&input);
|
||||
let McpRuntimeInput {
|
||||
startup_policy,
|
||||
config,
|
||||
@@ -218,7 +222,7 @@ impl McpConnectionSet {
|
||||
codex_apps_tools_cache_key,
|
||||
client_mcp_extensions,
|
||||
auth,
|
||||
codex_apps_auth_manager,
|
||||
auth_manager,
|
||||
elicitation_reviewer,
|
||||
elicitation_lifecycle,
|
||||
} = input;
|
||||
@@ -271,7 +275,7 @@ impl McpConnectionSet {
|
||||
let static_chatgpt_auth_provider = auth
|
||||
.filter(|auth| auth.uses_codex_backend())
|
||||
.map(codex_model_provider::auth_provider_from_auth);
|
||||
let codex_apps_auth_provider = codex_apps_auth_manager.and_then(|auth_manager| {
|
||||
let codex_apps_auth_provider = auth_manager.and_then(|auth_manager| {
|
||||
auth.filter(|auth| auth.uses_codex_backend()).map(|auth| {
|
||||
codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth)
|
||||
})
|
||||
@@ -682,6 +686,7 @@ impl McpConnectionSet {
|
||||
prefix_mcp_tool_names,
|
||||
non_prefixed_mcp_tool_servers,
|
||||
elicitation_requests: elicitation_requests.clone(),
|
||||
trusted_access,
|
||||
};
|
||||
let summary_publication_gate = publication_gate;
|
||||
tokio::spawn(async move {
|
||||
@@ -742,6 +747,7 @@ impl McpConnectionSet {
|
||||
prefix_mcp_tool_names,
|
||||
non_prefixed_mcp_tool_servers: Vec::new(),
|
||||
elicitation_requests: ElicitationRequestManager::default(),
|
||||
trusted_access: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +873,7 @@ impl McpConnectionSet {
|
||||
tool: &str,
|
||||
environment_id: Option<&str>,
|
||||
arguments: Option<serde_json::Value>,
|
||||
meta: Option<serde_json::Value>,
|
||||
mut meta: Option<serde_json::Value>,
|
||||
requested_timeout: Option<Duration>,
|
||||
wait_for_server: bool,
|
||||
) -> Result<CallToolResult> {
|
||||
@@ -911,6 +917,10 @@ impl McpConnectionSet {
|
||||
}
|
||||
(server_timeout, requested_timeout) => server_timeout.or(requested_timeout),
|
||||
};
|
||||
// Direct callers cannot supply host-owned entitlement metadata, even for unlisted tools.
|
||||
if let Some(serde_json::Value::Object(meta)) = meta.as_mut() {
|
||||
meta.remove(ENTITLEMENT_CONTEXT_KEY);
|
||||
}
|
||||
let result: rmcp::model::CallToolResult = client
|
||||
.client
|
||||
.call_tool(tool.to_string(), arguments, meta, effective_timeout)
|
||||
|
||||
@@ -36,8 +36,14 @@ use codex_connectors::ConnectorRuntimeContext;
|
||||
use codex_connectors::ConnectorRuntimeContextKey;
|
||||
use codex_connectors::ConnectorRuntimeFetchSource;
|
||||
use codex_connectors::ConnectorRuntimeManager;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use codex_exec_server::HttpRequestResponse;
|
||||
use codex_exec_server::HttpResponseBodyStream;
|
||||
use codex_exec_server_test_support::environment_manager_without_environments;
|
||||
use codex_login::AuthHeaders;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::ToolName;
|
||||
use codex_protocol::approvals::ElicitationRequest;
|
||||
@@ -115,6 +121,7 @@ impl McpConnectionSet {
|
||||
/*lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
),
|
||||
trusted_access: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +245,24 @@ fn create_test_server_info(title: &str) -> McpServerInfo {
|
||||
|
||||
struct TestInProcessTransportFactory;
|
||||
|
||||
struct PendingHttpClient;
|
||||
|
||||
impl HttpClient for PendingHttpClient {
|
||||
fn http_request(
|
||||
&self,
|
||||
_params: HttpRequestParams,
|
||||
) -> BoxFuture<'_, Result<HttpRequestResponse, ExecServerError>> {
|
||||
futures::future::pending().boxed()
|
||||
}
|
||||
|
||||
fn http_request_stream(
|
||||
&self,
|
||||
_params: HttpRequestParams,
|
||||
) -> BoxFuture<'_, Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError>> {
|
||||
futures::future::pending().boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl InProcessTransportFactory for TestInProcessTransportFactory {
|
||||
fn open(&self) -> BoxFuture<'static, io::Result<DuplexStream>> {
|
||||
async {
|
||||
@@ -438,6 +463,81 @@ async fn create_test_managed_client(tools: Vec<ToolInfo>) -> ManagedClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn prepared_call_timeout_includes_trusted_access_lookup() {
|
||||
let mut tool = create_test_tool("docs", "access");
|
||||
tool.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true));
|
||||
let mut tool_meta = rmcp::model::MetaObject::new();
|
||||
tool_meta.insert(
|
||||
"openai/requestedEntitlements".to_string(),
|
||||
serde_json::json!(["cyber_trusted_access"]),
|
||||
);
|
||||
tool.tool.meta = Some(tool_meta);
|
||||
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
||||
let mut manager = McpConnectionSet::new_uninitialized(
|
||||
&approval_policy,
|
||||
&permission_profile,
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
);
|
||||
let mut config = crate::mcp::tests::test_mcp_config(std::env::temp_dir());
|
||||
let mut catalog = crate::ResolvedMcpCatalog::builder();
|
||||
catalog.register(crate::McpServerRegistration::from_plugin(
|
||||
"docs".to_string(),
|
||||
crate::McpPluginAttribution::new("docs@test".to_string(), "Docs".to_string()),
|
||||
/*plugin_order*/ 0,
|
||||
serde_json::from_value(serde_json::json!({ "command": "docs" }))
|
||||
.expect("plugin MCP config"),
|
||||
));
|
||||
config.mcp_server_catalog = catalog.build();
|
||||
config
|
||||
.server_permission_profiles
|
||||
.insert("docs".to_string(), PermissionProfile::default());
|
||||
manager.tool_plugin_provenance = Arc::new(crate::tool_plugin_provenance(&config));
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
manager.trusted_access = Some(TrustedAccessContext::new(
|
||||
auth.clone(),
|
||||
AuthManager::from_auth_for_testing(auth),
|
||||
"https://chatgpt.com/backend-api".to_string(),
|
||||
Arc::new(PendingHttpClient),
|
||||
));
|
||||
let manager = Arc::new(manager);
|
||||
let server_metadata = McpServerMetadata {
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
pollutes_memory: true,
|
||||
origin: Some(McpServerOrigin::Stdio),
|
||||
supports_parallel_tool_calls: false,
|
||||
default_tools_approval_mode: None,
|
||||
tool_approval_modes: HashMap::new(),
|
||||
};
|
||||
let prepared = crate::PreparedMcpCall::new(
|
||||
manager,
|
||||
Arc::new(create_test_managed_client(vec![tool.clone()]).await),
|
||||
Arc::new(config),
|
||||
/*catalog_revision*/ 0,
|
||||
Arc::new(RwLock::new(0)),
|
||||
tool,
|
||||
server_metadata,
|
||||
Some("docs@test".to_string()),
|
||||
/*selected_plugin_server*/ false,
|
||||
)
|
||||
.expect("docs should retain its permission profile");
|
||||
|
||||
let started = tokio::time::Instant::now();
|
||||
let error = prepared
|
||||
.call(
|
||||
Some(serde_json::json!({})),
|
||||
/*meta*/ None,
|
||||
Some(Duration::from_secs(1)),
|
||||
)
|
||||
.await
|
||||
.expect_err("trusted access lookup should consume the call timeout");
|
||||
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(1));
|
||||
assert!(format!("{error:#}").contains("timed out awaiting tools/call after 1s"));
|
||||
}
|
||||
|
||||
async fn create_ready_async_managed_client(tools: Vec<ToolInfo>) -> AsyncManagedClient {
|
||||
AsyncManagedClient {
|
||||
client: futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Ok(
|
||||
@@ -1887,7 +1987,7 @@ async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow:
|
||||
codex_apps_tools_cache_key: cache_key,
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: None,
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
@@ -3817,7 +3917,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() -
|
||||
),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: Some(hosted_auth.clone()),
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
@@ -3936,7 +4036,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: None,
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
@@ -4333,7 +4433,7 @@ async fn reconcile_reusable_server(
|
||||
),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: None,
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
@@ -4787,7 +4887,7 @@ async fn reconciliation_replaces_connection_when_protocol_mode_changes() {
|
||||
),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: None,
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
@@ -4845,7 +4945,7 @@ async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabl
|
||||
),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: None,
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
|
||||
@@ -431,7 +431,7 @@ pub async fn read_mcp_resource(
|
||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: auth.cloned(),
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
@@ -506,7 +506,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: auth.cloned(),
|
||||
codex_apps_auth_manager: None,
|
||||
auth_manager: None,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ pub struct McpRuntimeInput {
|
||||
pub codex_apps_tools_cache_key: ConnectorRuntimeContextKey,
|
||||
pub client_mcp_extensions: ClientMcpExtensions,
|
||||
pub auth: Option<CodexAuth>,
|
||||
pub codex_apps_auth_manager: Option<Arc<AuthManager>>,
|
||||
pub auth_manager: Option<Arc<AuthManager>>,
|
||||
pub elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
pub elicitation_lifecycle: Option<ElicitationLifecycle>,
|
||||
}
|
||||
@@ -672,7 +672,7 @@ impl McpRuntimeContext {
|
||||
self.local_process_cwd.clone()
|
||||
}
|
||||
|
||||
fn local_http_client(&self) -> Arc<dyn HttpClient> {
|
||||
pub(crate) fn local_http_client(&self) -> Arc<dyn HttpClient> {
|
||||
Arc::clone(&self.local_http_client)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::connection_manager::McpConnectionSet;
|
||||
use crate::runtime::McpRuntimeInput;
|
||||
use crate::server::McpServerMetadata;
|
||||
use crate::server::McpServerOrigin;
|
||||
use crate::tools::ToolInfo;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::HttpHeader;
|
||||
use codex_exec_server::HttpRedirectPolicy;
|
||||
@@ -14,10 +19,55 @@ use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
const ENTITLEMENT_CONTEXT_KEY: &str = "openai/entitlementContext";
|
||||
pub(crate) const ENTITLEMENT_CONTEXT_KEY: &str = "openai/entitlementContext";
|
||||
const MAX_VERIFIED_ACCESS_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
const REQUESTED_ENTITLEMENTS_KEY: &str = "openai/requestedEntitlements";
|
||||
const CYBER_TRUSTED_ACCESS_ENTITLEMENT: &str = "cyber_trusted_access";
|
||||
const TRUSTED_ACCESS_TIMEOUT: Duration = Duration::from_millis(2_500);
|
||||
|
||||
impl McpConnectionSet {
|
||||
/// Installed and task-selected plugins may request supported advisory entitlement metadata.
|
||||
/// Model calls use the local, read-only, zero-argument boundary.
|
||||
pub(crate) async fn add_trusted_access_context(
|
||||
&self,
|
||||
tool: &ToolInfo,
|
||||
server: &McpServerMetadata,
|
||||
arguments: Option<&Value>,
|
||||
meta: Option<Value>,
|
||||
) -> Option<Value> {
|
||||
if tool
|
||||
.tool
|
||||
.meta
|
||||
.as_deref()
|
||||
.and_then(|meta| meta.get(REQUESTED_ENTITLEMENTS_KEY))
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|entitlements| {
|
||||
entitlements.iter().all(Value::is_string)
|
||||
&& entitlements.iter().any(|entitlement| {
|
||||
entitlement.as_str() == Some(CYBER_TRUSTED_ACCESS_ENTITLEMENT)
|
||||
})
|
||||
})
|
||||
&& self
|
||||
.plugin_id_for_mcp_server_name(&tool.server_name)
|
||||
.is_some()
|
||||
&& arguments.is_none_or(|arguments| arguments.as_object().is_some_and(Map::is_empty))
|
||||
&& server.environment_id == codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID
|
||||
&& matches!(server.origin, Some(McpServerOrigin::Stdio))
|
||||
&& tool
|
||||
.tool
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|annotations| annotations.read_only_hint)
|
||||
== Some(true)
|
||||
&& let Some(context) = self.trusted_access.as_ref()
|
||||
{
|
||||
context.add_context(meta).await
|
||||
} else {
|
||||
meta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VerifiedAccessResponse {
|
||||
programs: Vec<Value>,
|
||||
@@ -69,6 +119,22 @@ pub struct TrustedAccessContext {
|
||||
}
|
||||
|
||||
impl TrustedAccessContext {
|
||||
pub(crate) fn from_runtime(input: &McpRuntimeInput) -> Option<Self> {
|
||||
let auth = input.auth.as_ref()?;
|
||||
if !matches!(
|
||||
auth.api_auth_mode(),
|
||||
AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(Self::new(
|
||||
auth.clone(),
|
||||
input.auth_manager.clone()?,
|
||||
input.config.chatgpt_base_url.clone(),
|
||||
input.runtime_context.local_http_client(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
auth: CodexAuth,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
|
||||
@@ -268,7 +268,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: auth.clone(),
|
||||
codex_apps_auth_manager,
|
||||
auth_manager: codex_apps_auth_manager,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
})
|
||||
|
||||
@@ -358,10 +358,6 @@ impl Session {
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&self.services.auth_manager));
|
||||
|
||||
McpRuntimeInput {
|
||||
startup_policy: if matches!(desired.session_source, SessionSource::SubAgent(_)) {
|
||||
McpStartupPolicy::LazyWhenCached
|
||||
@@ -381,7 +377,7 @@ impl Session {
|
||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()),
|
||||
client_mcp_extensions: self.services.client_mcp_extensions.for_mcp_servers(),
|
||||
auth,
|
||||
codex_apps_auth_manager,
|
||||
auth_manager: Some(Arc::clone(&self.services.auth_manager)),
|
||||
elicitation_reviewer,
|
||||
elicitation_lifecycle: Some(self.mcp_elicitation_lifecycle()),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::ExternalAuth;
|
||||
use codex_login::ExternalAuthFuture;
|
||||
use codex_login::ExternalAuthRefreshContext;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use wiremock::Mock;
|
||||
use wiremock::Request;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AccessCase {
|
||||
InstalledStdio,
|
||||
UndeclaredDaybreakName,
|
||||
ExternalTokens,
|
||||
#[cfg(unix)]
|
||||
RemoteStdio,
|
||||
UnownedStdio,
|
||||
InstalledHttp,
|
||||
MutableStdio,
|
||||
NonemptyArguments,
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
#[test_case(AccessCase::InstalledStdio; "installed local plugin")]
|
||||
#[test_case(AccessCase::UndeclaredDaybreakName; "undeclared Daybreak tool name")]
|
||||
#[test_case(AccessCase::ExternalTokens; "external ChatGPT tokens")]
|
||||
#[cfg_attr(unix, test_case(AccessCase::RemoteStdio; "installed remote stdio plugin"))]
|
||||
#[test_case(AccessCase::UnownedStdio; "unowned local server")]
|
||||
#[test_case(AccessCase::InstalledHttp; "HTTP plugin in default environment")]
|
||||
#[test_case(AccessCase::MutableStdio; "mutable plugin tool")]
|
||||
#[test_case(AccessCase::NonemptyArguments; "nonempty arguments")]
|
||||
#[test_case(AccessCase::ApiKey; "API key session")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn daybreak_metadata_follows_the_actual_plugin_call(case: AccessCase) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
#[cfg(unix)]
|
||||
let remote = if matches!(case, AccessCase::RemoteStdio) {
|
||||
Some(super::multi_exec_server_sandbox::ExecServerProcess::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let remote_url = remote.as_ref().map(|remote| remote.websocket_url.clone());
|
||||
#[cfg(not(unix))]
|
||||
let remote_url: Option<String> = None;
|
||||
let tool_name = if matches!(
|
||||
case,
|
||||
AccessCase::UndeclaredDaybreakName | AccessCase::InstalledHttp
|
||||
) {
|
||||
"get_daybreak_access"
|
||||
} else {
|
||||
"get_codex_security_daybreak_access"
|
||||
};
|
||||
let server = responses::start_mock_server().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/accounts/verified_access"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"programs": [{"program":"cyber", "state":"active", "grants":[
|
||||
{"level":"tac1", "source":"individual"}
|
||||
]}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/remote-mcp"))
|
||||
.respond_with(|request: &Request| {
|
||||
let body: Value = request.body_json().expect("MCP JSON-RPC request");
|
||||
let result = match body["method"].as_str() {
|
||||
Some("initialize") => json!({
|
||||
"protocolVersion": body["params"]["protocolVersion"],
|
||||
"capabilities":{"tools":{}},
|
||||
"serverInfo":{"name":"daybreak-test", "version":"1"}
|
||||
}),
|
||||
Some("tools/list") => json!({"tools":[{
|
||||
"name":"get_daybreak_access", "description":"Check Daybreak access",
|
||||
"inputSchema":{"type":"object", "properties":{}},
|
||||
"annotations":{"readOnlyHint":true}
|
||||
}]}),
|
||||
Some("tools/call") => json!({
|
||||
"content":[], "structuredContent":body["params"]["_meta"]
|
||||
}),
|
||||
_ => return ResponseTemplate::new(202),
|
||||
};
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"jsonrpc":"2.0", "id":body["id"], "result":result
|
||||
}))
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let arguments = if matches!(case, AccessCase::NonemptyArguments) {
|
||||
r#"{"refresh":true}"#
|
||||
} else {
|
||||
"{}"
|
||||
};
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let plugin_root = super::plugins::write_sample_plugin_manifest_and_config(&home);
|
||||
let server_config = if matches!(case, AccessCase::InstalledHttp) {
|
||||
json!({"url":format!("{}/remote-mcp", server.uri())})
|
||||
} else {
|
||||
json!({
|
||||
"command":stdio_server_bin()?,
|
||||
"cwd":plugin_root,
|
||||
"environment_id": if remote_url.is_some() { "remote" } else { "local" },
|
||||
"default_tools_approval_mode":"approve",
|
||||
"env":{"MCP_TEST_DAYBREAK_READ_ONLY":
|
||||
if matches!(case, AccessCase::MutableStdio) { "false" } else { "true" }}
|
||||
})
|
||||
};
|
||||
if matches!(case, AccessCase::UnownedStdio) {
|
||||
let config_path = home.path().join("config.toml");
|
||||
let existing = std::fs::read_to_string(&config_path)?;
|
||||
let extra = toml::to_string(&json!({"mcp_servers":{"sample":server_config}}))?;
|
||||
std::fs::write(config_path, format!("{existing}\n{extra}"))?;
|
||||
} else {
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"mcpServers":{"sample":server_config}
|
||||
}))?,
|
||||
)?;
|
||||
}
|
||||
let auth = if matches!(case, AccessCase::ApiKey) {
|
||||
CodexAuth::from_api_key("test-api-key")
|
||||
} else if matches!(case, AccessCase::ExternalTokens) {
|
||||
CodexAuth::from_external_chatgpt_tokens(
|
||||
"header.e30.external",
|
||||
"external-account",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)?
|
||||
} else {
|
||||
CodexAuth::create_dummy_chatgpt_auth_for_testing()
|
||||
};
|
||||
let base_url = server.uri();
|
||||
let mut builder = test_codex()
|
||||
.with_home(home)
|
||||
.with_auth(auth)
|
||||
.with_config(move |config| config.chatgpt_base_url = base_url);
|
||||
if let Some(remote_url) = &remote_url {
|
||||
builder = builder.with_exec_server_url(remote_url.clone());
|
||||
}
|
||||
let test = builder.build_with_remote_and_local_env(&server).await?;
|
||||
if remote_url.is_some() {
|
||||
assert!(
|
||||
test.thread_manager
|
||||
.environment_manager()
|
||||
.get_environment("remote")
|
||||
.expect("remote executor")
|
||||
.is_remote()
|
||||
);
|
||||
let (config, _) = test.codex.current_mcp_config_and_runtime_context().await;
|
||||
assert_eq!(
|
||||
config.mcp_server_catalog.configured_servers()["sample"].environment_id,
|
||||
"remote"
|
||||
);
|
||||
}
|
||||
wait_for_mcp_server(&test.codex, "sample").await?;
|
||||
let mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_tool_search_call("search", &json!({"query":tool_name})),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_function_call_with_namespace(
|
||||
"daybreak-call",
|
||||
"mcp__sample",
|
||||
tool_name,
|
||||
arguments,
|
||||
),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("done", "done"),
|
||||
responses::ev_completed("resp-3"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "Call the sample Daybreak access tool".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
let end = wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::McpToolCallEnd(_))
|
||||
})
|
||||
.await;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
let EventMsg::McpToolCallEnd(end) = end else {
|
||||
unreachable!()
|
||||
};
|
||||
let metadata = end
|
||||
.result
|
||||
.expect("successful MCP call")
|
||||
.structured_content
|
||||
.expect("echoed metadata");
|
||||
let expected_context = match case {
|
||||
AccessCase::InstalledStdio | AccessCase::ExternalTokens => {
|
||||
Some(json!({"schemaVersion":1, "entitlements":{
|
||||
"cyber_trusted_access":{"schemaVersion":1,"status":"granted","grants":[
|
||||
{"level":"tac1","source":"user"}
|
||||
],"stale":false}
|
||||
}}))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
AccessCase::RemoteStdio => None,
|
||||
AccessCase::UndeclaredDaybreakName
|
||||
| AccessCase::UnownedStdio
|
||||
| AccessCase::InstalledHttp
|
||||
| AccessCase::MutableStdio
|
||||
| AccessCase::NonemptyArguments
|
||||
| AccessCase::ApiKey => None,
|
||||
};
|
||||
assert_eq!(
|
||||
metadata.get("openai/entitlementContext"),
|
||||
expected_context.as_ref()
|
||||
);
|
||||
assert_eq!(
|
||||
end.plugin_id.as_deref(),
|
||||
if matches!(case, AccessCase::UnownedStdio) {
|
||||
None
|
||||
} else {
|
||||
Some("sample@test")
|
||||
}
|
||||
);
|
||||
assert!(metadata["threadId"].is_string());
|
||||
assert!(!metadata.to_string().contains("Access Token"));
|
||||
assert!(!metadata.to_string().contains("header.e30.external"));
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let access_requests: Vec<_> = requests
|
||||
.iter()
|
||||
.filter(|request| request.url.path() == "/accounts/verified_access")
|
||||
.collect();
|
||||
assert_eq!(
|
||||
access_requests.len(),
|
||||
usize::from(matches!(
|
||||
case,
|
||||
AccessCase::InstalledStdio | AccessCase::ExternalTokens
|
||||
))
|
||||
);
|
||||
if let Some(request) = access_requests.first() {
|
||||
let (authorization, account_id) = if matches!(case, AccessCase::ExternalTokens) {
|
||||
("Bearer header.e30.external", "external-account")
|
||||
} else {
|
||||
("Bearer Access Token", "account_id")
|
||||
};
|
||||
assert_eq!(request.headers["authorization"], authorization);
|
||||
assert_eq!(request.headers["chatgpt-account-id"], account_id);
|
||||
}
|
||||
assert!(
|
||||
mock.requests()[2]
|
||||
.function_call_output("daybreak-call")
|
||||
.is_object()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SelectedAuth(CodexAuth);
|
||||
|
||||
impl ExternalAuth for SelectedAuth {
|
||||
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
|
||||
Box::pin(async { Ok(self.0.clone()) })
|
||||
}
|
||||
|
||||
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
|
||||
Box::pin(async { Ok(self.0.clone()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn daybreak_discards_in_flight_account_changes_without_apps() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = responses::start_mock_server().await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let base_url = format!("http://{}", listener.local_addr()?);
|
||||
let (request_tx, request_rx) = oneshot::channel();
|
||||
let (release_tx, release_rx) = oneshot::channel();
|
||||
let response_task = tokio::spawn(async move {
|
||||
let (mut stream, request) = loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let mut stream = BufReader::new(stream);
|
||||
let mut request = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = stream.read_line(&mut line).await?;
|
||||
if read == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
request.push_str(&line);
|
||||
}
|
||||
if request.starts_with("GET /accounts/verified_access ") {
|
||||
break (stream, request);
|
||||
}
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(
|
||||
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await?;
|
||||
};
|
||||
request_tx.send(request).expect("request receiver");
|
||||
release_rx.await?;
|
||||
let body = json!({"programs":[{"program":"cyber","state":"active","grants":[
|
||||
{"level":"tac1","source":"individual"}
|
||||
]}]})
|
||||
.to_string();
|
||||
stream.get_mut().write_all(format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
).as_bytes()).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let plugin_root = super::plugins::write_sample_plugin_manifest_and_config(&home);
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"mcpServers":{"sample":{"command":stdio_server_bin()?,"cwd":plugin_root,
|
||||
"environment_id":"local","default_tools_approval_mode":"approve",
|
||||
"env":{"MCP_TEST_DAYBREAK_READ_ONLY":"true"}}}
|
||||
}))?,
|
||||
)?;
|
||||
let test = test_codex()
|
||||
.with_home(home)
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(move |config| {
|
||||
config.chatgpt_base_url = base_url;
|
||||
config
|
||||
.features
|
||||
.disable(Feature::Apps)
|
||||
.expect("disable Apps");
|
||||
})
|
||||
.build_with_remote_and_local_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&test.codex, "sample").await?;
|
||||
responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_tool_search_call(
|
||||
"search",
|
||||
&json!({"query":"get_codex_security_daybreak_access"}),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_function_call_with_namespace(
|
||||
"daybreak-call",
|
||||
"mcp__sample",
|
||||
"get_codex_security_daybreak_access",
|
||||
"{}",
|
||||
),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("done", "done"),
|
||||
responses::ev_completed("resp-3"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "Call the sample Daybreak access tool".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
let call = wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::McpToolCallEnd(_))
|
||||
});
|
||||
let switch = async {
|
||||
let request =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), request_rx).await??;
|
||||
assert!(request.starts_with("GET /accounts/verified_access "));
|
||||
assert!(
|
||||
request
|
||||
.to_ascii_lowercase()
|
||||
.contains("chatgpt-account-id: account_id")
|
||||
);
|
||||
test.thread_manager
|
||||
.auth_manager()
|
||||
.set_external_auth(Arc::new(SelectedAuth(
|
||||
CodexAuth::from_external_chatgpt_tokens(
|
||||
"header.e30.changed",
|
||||
"other-account",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)?,
|
||||
)))
|
||||
.await?;
|
||||
release_tx.send(()).expect("release response");
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
let (result, switched) = tokio::join!(call, switch);
|
||||
switched?;
|
||||
response_task.await??;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
let EventMsg::McpToolCallEnd(result) = result else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
result
|
||||
.result
|
||||
.expect("successful call")
|
||||
.structured_content
|
||||
.expect("metadata")["openai/entitlementContext"],
|
||||
json!({
|
||||
"schemaVersion":1,"entitlements":{"cyber_trusted_access":{
|
||||
"schemaVersion":1,"status":"unknown","grants":[],"stale":false
|
||||
}}
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -108,7 +108,7 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token(
|
||||
codex_apps_tools_cache_key: codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)),
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: Some(expected_auth.clone()),
|
||||
codex_apps_auth_manager: Some(Arc::clone(&auth_manager)),
|
||||
auth_manager: Some(Arc::clone(&auth_manager)),
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
})
|
||||
|
||||
@@ -62,6 +62,7 @@ mod context_annotations;
|
||||
mod current_time_reminder;
|
||||
mod cyber_access_program;
|
||||
mod cyber_exec_policy;
|
||||
mod daybreak_access;
|
||||
mod deprecation_notice;
|
||||
mod exec;
|
||||
mod exec_policy;
|
||||
|
||||
@@ -43,15 +43,15 @@ const SECOND_CALL_ID: &str = "write-from-second";
|
||||
const EXEC_SERVER_START_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
struct ExecServerProcess {
|
||||
pub(super) struct ExecServerProcess {
|
||||
_codex_home: TempDir,
|
||||
child: Child,
|
||||
_stdout: BufReader<ChildStdout>,
|
||||
websocket_url: String,
|
||||
pub(super) websocket_url: String,
|
||||
}
|
||||
|
||||
impl ExecServerProcess {
|
||||
async fn start() -> Result<Self> {
|
||||
pub(super) async fn start() -> Result<Self> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut child = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
||||
.args(["exec-server", "--listen", "ws://127.0.0.1:0"])
|
||||
|
||||
@@ -79,6 +79,28 @@ impl TestToolServer {
|
||||
Arc::new(sandbox_meta_schema),
|
||||
);
|
||||
sandbox_meta_tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
let entitlement_tools = std::env::var("MCP_TEST_DAYBREAK_READ_ONLY")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|read_only| {
|
||||
["get_codex_security_daybreak_access", "get_daybreak_access"].map(|name| {
|
||||
let mut tool = sandbox_meta_tool.clone();
|
||||
tool.name = Cow::Borrowed(name);
|
||||
tool.description =
|
||||
Some(Cow::Borrowed("Return requested account access metadata."));
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(read_only == "true"));
|
||||
if name == "get_codex_security_daybreak_access" {
|
||||
let mut meta = MetaObject::new();
|
||||
meta.insert(
|
||||
"openai/requestedEntitlements".to_string(),
|
||||
json!(["cyber_trusted_access"]),
|
||||
);
|
||||
tool.meta = Some(meta);
|
||||
}
|
||||
tool
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
let thread_hint_schema: JsonObject = serde_json::from_value(json!({
|
||||
@@ -124,6 +146,7 @@ impl TestToolServer {
|
||||
Self::image_scenario_tool(),
|
||||
sandbox_meta_tool,
|
||||
];
|
||||
tools.extend(entitlement_tools);
|
||||
if std::env::var_os("MCP_TEST_ENABLE_NODE_REPL_JS").is_some() {
|
||||
#[expect(clippy::expect_used)]
|
||||
let schema: JsonObject = serde_json::from_value(json!({
|
||||
@@ -662,9 +685,9 @@ impl ServerHandler for TestToolServer {
|
||||
.supports_openai_form_elicitation
|
||||
.load(Ordering::Relaxed),
|
||||
}))),
|
||||
"sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object(
|
||||
context.meta.0.0,
|
||||
))),
|
||||
"sandbox_meta" | "get_codex_security_daybreak_access" | "get_daybreak_access" => Ok(
|
||||
Self::structured_result(serde_json::Value::Object(context.meta.0.0)),
|
||||
),
|
||||
"cwd" => {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
|
||||
Reference in New Issue
Block a user