mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## 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
230 lines
8.7 KiB
Rust
230 lines
8.7 KiB
Rust
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(())
|
|
}
|