mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +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:
1 parent
e56e4922eb
commit
ae357e7250
16 files changed
+935
-31
No files matched your search
@@ -97,7 +97,7 @@ impl AppsRequestProcessor {
|
|||||||
codex_apps_tools_cache_key: cache_key.clone(),
|
codex_apps_tools_cache_key: cache_key.clone(),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: auth.clone(),
|
auth: auth.clone(),
|
||||||
codex_apps_auth_manager,
|
auth_manager: codex_apps_auth_manager,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: 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 curated_mcp_sync;
|
||||||
mod current_time;
|
mod current_time;
|
||||||
mod cyber_access_program;
|
mod cyber_access_program;
|
||||||
|
mod daybreak_access;
|
||||||
mod dynamic_tools;
|
mod dynamic_tools;
|
||||||
mod environment_add;
|
mod environment_add;
|
||||||
mod environment_info;
|
mod environment_info;
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ impl fmt::Debug for McpBinding {
|
|||||||
/// one [`McpBinding`].
|
/// one [`McpBinding`].
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PreparedMcpCall {
|
pub struct PreparedMcpCall {
|
||||||
_connections: Arc<McpConnectionSet>,
|
connections: Arc<McpConnectionSet>,
|
||||||
client: Arc<ManagedClient>,
|
client: Arc<ManagedClient>,
|
||||||
config: Arc<McpConfig>,
|
config: Arc<McpConfig>,
|
||||||
catalog_revision: u64,
|
catalog_revision: u64,
|
||||||
@@ -201,7 +201,7 @@ impl PreparedMcpCall {
|
|||||||
let server_name = tool_info.server_name.clone();
|
let server_name = tool_info.server_name.clone();
|
||||||
config.permission_profile_for_server(&server_name)?;
|
config.permission_profile_for_server(&server_name)?;
|
||||||
Some(Self {
|
Some(Self {
|
||||||
_connections: connections,
|
connections,
|
||||||
client,
|
client,
|
||||||
config,
|
config,
|
||||||
catalog_revision,
|
catalog_revision,
|
||||||
@@ -321,10 +321,40 @@ impl PreparedMcpCall {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let (arguments, meta) = prepare().await?;
|
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
|
let result = self
|
||||||
.client
|
.client
|
||||||
.client
|
.client
|
||||||
.call_tool(tool_name.clone(), arguments, meta, effective_timeout)
|
.call_tool(tool_name.clone(), arguments, meta, remaining_timeout)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("tool call failed for `{}/{tool_name}`", self.server_name))?;
|
.with_context(|| format!("tool call failed for `{}/{tool_name}`", self.server_name))?;
|
||||||
drop(current_revision);
|
drop(current_revision);
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ use crate::tool_catalog_cache::McpToolCatalogCacheContext;
|
|||||||
use crate::tools::ToolFilter;
|
use crate::tools::ToolFilter;
|
||||||
use crate::tools::ToolInfo;
|
use crate::tools::ToolInfo;
|
||||||
use crate::tools::filter_tools;
|
use crate::tools::filter_tools;
|
||||||
|
use crate::trusted_access::ENTITLEMENT_CONTEXT_KEY;
|
||||||
|
use crate::trusted_access::TrustedAccessContext;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
@@ -192,6 +194,7 @@ pub(crate) struct McpConnectionSet {
|
|||||||
prefix_mcp_tool_names: bool,
|
prefix_mcp_tool_names: bool,
|
||||||
non_prefixed_mcp_tool_servers: Vec<String>,
|
non_prefixed_mcp_tool_servers: Vec<String>,
|
||||||
elicitation_requests: ElicitationRequestManager,
|
elicitation_requests: ElicitationRequestManager,
|
||||||
|
pub(crate) trusted_access: Option<TrustedAccessContext>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpConnectionSet {
|
impl McpConnectionSet {
|
||||||
@@ -203,6 +206,7 @@ impl McpConnectionSet {
|
|||||||
input: McpRuntimeInput,
|
input: McpRuntimeInput,
|
||||||
elicitation_router: ElicitationRequestRouter,
|
elicitation_router: ElicitationRequestRouter,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let trusted_access = TrustedAccessContext::from_runtime(&input);
|
||||||
let McpRuntimeInput {
|
let McpRuntimeInput {
|
||||||
startup_policy,
|
startup_policy,
|
||||||
config,
|
config,
|
||||||
@@ -218,7 +222,7 @@ impl McpConnectionSet {
|
|||||||
codex_apps_tools_cache_key,
|
codex_apps_tools_cache_key,
|
||||||
client_mcp_extensions,
|
client_mcp_extensions,
|
||||||
auth,
|
auth,
|
||||||
codex_apps_auth_manager,
|
auth_manager,
|
||||||
elicitation_reviewer,
|
elicitation_reviewer,
|
||||||
elicitation_lifecycle,
|
elicitation_lifecycle,
|
||||||
} = input;
|
} = input;
|
||||||
@@ -271,7 +275,7 @@ impl McpConnectionSet {
|
|||||||
let static_chatgpt_auth_provider = auth
|
let static_chatgpt_auth_provider = auth
|
||||||
.filter(|auth| auth.uses_codex_backend())
|
.filter(|auth| auth.uses_codex_backend())
|
||||||
.map(codex_model_provider::auth_provider_from_auth);
|
.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| {
|
auth.filter(|auth| auth.uses_codex_backend()).map(|auth| {
|
||||||
codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth)
|
codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth)
|
||||||
})
|
})
|
||||||
@@ -682,6 +686,7 @@ impl McpConnectionSet {
|
|||||||
prefix_mcp_tool_names,
|
prefix_mcp_tool_names,
|
||||||
non_prefixed_mcp_tool_servers,
|
non_prefixed_mcp_tool_servers,
|
||||||
elicitation_requests: elicitation_requests.clone(),
|
elicitation_requests: elicitation_requests.clone(),
|
||||||
|
trusted_access,
|
||||||
};
|
};
|
||||||
let summary_publication_gate = publication_gate;
|
let summary_publication_gate = publication_gate;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -742,6 +747,7 @@ impl McpConnectionSet {
|
|||||||
prefix_mcp_tool_names,
|
prefix_mcp_tool_names,
|
||||||
non_prefixed_mcp_tool_servers: Vec::new(),
|
non_prefixed_mcp_tool_servers: Vec::new(),
|
||||||
elicitation_requests: ElicitationRequestManager::default(),
|
elicitation_requests: ElicitationRequestManager::default(),
|
||||||
|
trusted_access: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,7 +873,7 @@ impl McpConnectionSet {
|
|||||||
tool: &str,
|
tool: &str,
|
||||||
environment_id: Option<&str>,
|
environment_id: Option<&str>,
|
||||||
arguments: Option<serde_json::Value>,
|
arguments: Option<serde_json::Value>,
|
||||||
meta: Option<serde_json::Value>,
|
mut meta: Option<serde_json::Value>,
|
||||||
requested_timeout: Option<Duration>,
|
requested_timeout: Option<Duration>,
|
||||||
wait_for_server: bool,
|
wait_for_server: bool,
|
||||||
) -> Result<CallToolResult> {
|
) -> Result<CallToolResult> {
|
||||||
@@ -911,6 +917,10 @@ impl McpConnectionSet {
|
|||||||
}
|
}
|
||||||
(server_timeout, requested_timeout) => server_timeout.or(requested_timeout),
|
(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
|
let result: rmcp::model::CallToolResult = client
|
||||||
.client
|
.client
|
||||||
.call_tool(tool.to_string(), arguments, meta, effective_timeout)
|
.call_tool(tool.to_string(), arguments, meta, effective_timeout)
|
||||||
|
|||||||
@@ -36,8 +36,14 @@ use codex_connectors::ConnectorRuntimeContext;
|
|||||||
use codex_connectors::ConnectorRuntimeContextKey;
|
use codex_connectors::ConnectorRuntimeContextKey;
|
||||||
use codex_connectors::ConnectorRuntimeFetchSource;
|
use codex_connectors::ConnectorRuntimeFetchSource;
|
||||||
use codex_connectors::ConnectorRuntimeManager;
|
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_exec_server_test_support::environment_manager_without_environments;
|
||||||
use codex_login::AuthHeaders;
|
use codex_login::AuthHeaders;
|
||||||
|
use codex_login::AuthManager;
|
||||||
use codex_login::CodexAuth;
|
use codex_login::CodexAuth;
|
||||||
use codex_protocol::ToolName;
|
use codex_protocol::ToolName;
|
||||||
use codex_protocol::approvals::ElicitationRequest;
|
use codex_protocol::approvals::ElicitationRequest;
|
||||||
@@ -115,6 +121,7 @@ impl McpConnectionSet {
|
|||||||
/*lifecycle*/ None,
|
/*lifecycle*/ None,
|
||||||
ElicitationRequestRouter::default(),
|
ElicitationRequestRouter::default(),
|
||||||
),
|
),
|
||||||
|
trusted_access: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +245,24 @@ fn create_test_server_info(title: &str) -> McpServerInfo {
|
|||||||
|
|
||||||
struct TestInProcessTransportFactory;
|
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 {
|
impl InProcessTransportFactory for TestInProcessTransportFactory {
|
||||||
fn open(&self) -> BoxFuture<'static, io::Result<DuplexStream>> {
|
fn open(&self) -> BoxFuture<'static, io::Result<DuplexStream>> {
|
||||||
async {
|
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 {
|
async fn create_ready_async_managed_client(tools: Vec<ToolInfo>) -> AsyncManagedClient {
|
||||||
AsyncManagedClient {
|
AsyncManagedClient {
|
||||||
client: futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Ok(
|
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,
|
codex_apps_tools_cache_key: cache_key,
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: None,
|
auth: None,
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: None,
|
elicitation_lifecycle: None,
|
||||||
},
|
},
|
||||||
@@ -3817,7 +3917,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() -
|
|||||||
),
|
),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: Some(hosted_auth.clone()),
|
auth: Some(hosted_auth.clone()),
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: 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(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: None,
|
auth: None,
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: None,
|
elicitation_lifecycle: None,
|
||||||
},
|
},
|
||||||
@@ -4333,7 +4433,7 @@ async fn reconcile_reusable_server(
|
|||||||
),
|
),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: None,
|
auth: None,
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: None,
|
elicitation_lifecycle: None,
|
||||||
},
|
},
|
||||||
@@ -4787,7 +4887,7 @@ async fn reconciliation_replaces_connection_when_protocol_mode_changes() {
|
|||||||
),
|
),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: None,
|
auth: None,
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: 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(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: None,
|
auth: None,
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: None,
|
elicitation_lifecycle: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ pub async fn read_mcp_resource(
|
|||||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth),
|
codex_apps_tools_cache_key: connector_runtime_context_key(auth),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: auth.cloned(),
|
auth: auth.cloned(),
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: 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),
|
codex_apps_tools_cache_key: connector_runtime_context_key(auth),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: auth.cloned(),
|
auth: auth.cloned(),
|
||||||
codex_apps_auth_manager: None,
|
auth_manager: None,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: None,
|
elicitation_lifecycle: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ pub struct McpRuntimeInput {
|
|||||||
pub codex_apps_tools_cache_key: ConnectorRuntimeContextKey,
|
pub codex_apps_tools_cache_key: ConnectorRuntimeContextKey,
|
||||||
pub client_mcp_extensions: ClientMcpExtensions,
|
pub client_mcp_extensions: ClientMcpExtensions,
|
||||||
pub auth: Option<CodexAuth>,
|
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_reviewer: Option<ElicitationReviewerHandle>,
|
||||||
pub elicitation_lifecycle: Option<ElicitationLifecycle>,
|
pub elicitation_lifecycle: Option<ElicitationLifecycle>,
|
||||||
}
|
}
|
||||||
@@ -672,7 +672,7 @@ impl McpRuntimeContext {
|
|||||||
self.local_process_cwd.clone()
|
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)
|
Arc::clone(&self.local_http_client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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::HttpClient;
|
||||||
use codex_exec_server::HttpHeader;
|
use codex_exec_server::HttpHeader;
|
||||||
use codex_exec_server::HttpRedirectPolicy;
|
use codex_exec_server::HttpRedirectPolicy;
|
||||||
@@ -14,10 +19,55 @@ use serde_json::Map;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_json::json;
|
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 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);
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct VerifiedAccessResponse {
|
struct VerifiedAccessResponse {
|
||||||
programs: Vec<Value>,
|
programs: Vec<Value>,
|
||||||
@@ -69,6 +119,22 @@ pub struct TrustedAccessContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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(
|
pub fn new(
|
||||||
auth: CodexAuth,
|
auth: CodexAuth,
|
||||||
auth_manager: Arc<AuthManager>,
|
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()),
|
codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: auth.clone(),
|
auth: auth.clone(),
|
||||||
codex_apps_auth_manager,
|
auth_manager: codex_apps_auth_manager,
|
||||||
elicitation_reviewer: None,
|
elicitation_reviewer: None,
|
||||||
elicitation_lifecycle: None,
|
elicitation_lifecycle: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -358,10 +358,6 @@ impl Session {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.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 {
|
McpRuntimeInput {
|
||||||
startup_policy: if matches!(desired.session_source, SessionSource::SubAgent(_)) {
|
startup_policy: if matches!(desired.session_source, SessionSource::SubAgent(_)) {
|
||||||
McpStartupPolicy::LazyWhenCached
|
McpStartupPolicy::LazyWhenCached
|
||||||
@@ -381,7 +377,7 @@ impl Session {
|
|||||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()),
|
codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()),
|
||||||
client_mcp_extensions: self.services.client_mcp_extensions.for_mcp_servers(),
|
client_mcp_extensions: self.services.client_mcp_extensions.for_mcp_servers(),
|
||||||
auth,
|
auth,
|
||||||
codex_apps_auth_manager,
|
auth_manager: Some(Arc::clone(&self.services.auth_manager)),
|
||||||
elicitation_reviewer,
|
elicitation_reviewer,
|
||||||
elicitation_lifecycle: Some(self.mcp_elicitation_lifecycle()),
|
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)),
|
codex_apps_tools_cache_key: codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)),
|
||||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||||
auth: Some(expected_auth.clone()),
|
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_reviewer: None,
|
||||||
elicitation_lifecycle: None,
|
elicitation_lifecycle: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ mod context_annotations;
|
|||||||
mod current_time_reminder;
|
mod current_time_reminder;
|
||||||
mod cyber_access_program;
|
mod cyber_access_program;
|
||||||
mod cyber_exec_policy;
|
mod cyber_exec_policy;
|
||||||
|
mod daybreak_access;
|
||||||
mod deprecation_notice;
|
mod deprecation_notice;
|
||||||
mod exec;
|
mod exec;
|
||||||
mod exec_policy;
|
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 EXEC_SERVER_START_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);
|
const TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
struct ExecServerProcess {
|
pub(super) struct ExecServerProcess {
|
||||||
_codex_home: TempDir,
|
_codex_home: TempDir,
|
||||||
child: Child,
|
child: Child,
|
||||||
_stdout: BufReader<ChildStdout>,
|
_stdout: BufReader<ChildStdout>,
|
||||||
websocket_url: String,
|
pub(super) websocket_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExecServerProcess {
|
impl ExecServerProcess {
|
||||||
async fn start() -> Result<Self> {
|
pub(super) async fn start() -> Result<Self> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
let mut child = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
let mut child = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
||||||
.args(["exec-server", "--listen", "ws://127.0.0.1:0"])
|
.args(["exec-server", "--listen", "ws://127.0.0.1:0"])
|
||||||
|
|||||||
@@ -79,6 +79,28 @@ impl TestToolServer {
|
|||||||
Arc::new(sandbox_meta_schema),
|
Arc::new(sandbox_meta_schema),
|
||||||
);
|
);
|
||||||
sandbox_meta_tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
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)]
|
#[expect(clippy::expect_used)]
|
||||||
let thread_hint_schema: JsonObject = serde_json::from_value(json!({
|
let thread_hint_schema: JsonObject = serde_json::from_value(json!({
|
||||||
@@ -124,6 +146,7 @@ impl TestToolServer {
|
|||||||
Self::image_scenario_tool(),
|
Self::image_scenario_tool(),
|
||||||
sandbox_meta_tool,
|
sandbox_meta_tool,
|
||||||
];
|
];
|
||||||
|
tools.extend(entitlement_tools);
|
||||||
if std::env::var_os("MCP_TEST_ENABLE_NODE_REPL_JS").is_some() {
|
if std::env::var_os("MCP_TEST_ENABLE_NODE_REPL_JS").is_some() {
|
||||||
#[expect(clippy::expect_used)]
|
#[expect(clippy::expect_used)]
|
||||||
let schema: JsonObject = serde_json::from_value(json!({
|
let schema: JsonObject = serde_json::from_value(json!({
|
||||||
@@ -662,9 +685,9 @@ impl ServerHandler for TestToolServer {
|
|||||||
.supports_openai_form_elicitation
|
.supports_openai_form_elicitation
|
||||||
.load(Ordering::Relaxed),
|
.load(Ordering::Relaxed),
|
||||||
}))),
|
}))),
|
||||||
"sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object(
|
"sandbox_meta" | "get_codex_security_daybreak_access" | "get_daybreak_access" => Ok(
|
||||||
context.meta.0.0,
|
Self::structured_result(serde_json::Value::Object(context.meta.0.0)),
|
||||||
))),
|
),
|
||||||
"cwd" => {
|
"cwd" => {
|
||||||
let cwd = std::env::current_dir()
|
let cwd = std::env::current_dir()
|
||||||
.map(|path| path.to_string_lossy().into_owned())
|
.map(|path| path.to_string_lossy().into_owned())
|
||||||
|
|||||||
Reference in new issue
Block a user