diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6b0947b8b1b1..ab630ef467da 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1976,6 +1976,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-file-watcher", + "codex-git-attribution", "codex-git-utils", "codex-goal-extension", "codex-guardian", @@ -2330,7 +2331,9 @@ dependencies = [ "codex-exec", "codex-exec-server", "codex-execpolicy", + "codex-extension-api", "codex-features", + "codex-git-attribution", "codex-git-utils", "codex-home", "codex-http-client", @@ -3121,8 +3124,6 @@ dependencies = [ "codex-extension-api", "codex-http-client", "codex-login", - "codex-protocol", - "pretty_assertions", "serde_json", "tokio", "wiremock", @@ -3442,11 +3443,13 @@ name = "codex-mcp-server" version = "0.0.0" dependencies = [ "anyhow", + "app_test_support", "codex-arg0", "codex-config", "codex-core", "codex-exec-server", "codex-extension-api", + "codex-git-attribution", "codex-home", "codex-image-generation-extension", "codex-login", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 25f7de54c357..8aa7d7f0b29e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -185,6 +185,7 @@ codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } codex-extension-items = { path = "ext/items" } codex-goal-extension = { path = "ext/goal" } +codex-git-attribution = { path = "ext/git-attribution" } codex-guardian = { path = "ext/guardian" } codex-image-generation-extension = { path = "ext/image-generation" } codex-external-agent-migration = { path = "external-agent-migration" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 27eee2b83747..ef18d17b87b0 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -47,6 +47,7 @@ codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } codex-features = { workspace = true } codex-goal-extension = { workspace = true } +codex-git-attribution = { workspace = true } codex-guardian = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 86258e662015..10fa319f62fc 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -16,6 +16,7 @@ use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; use codex_goal_extension::GoalService; +use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; @@ -37,6 +38,8 @@ pub(crate) struct ThreadExtensionDependencies { pub(crate) goal_service: Arc, pub(crate) environment_manager: Arc, pub(crate) executor_skill_provider: Arc, + pub(crate) git_attribution_base_url: String, + pub(crate) http_client_factory: HttpClientFactory, /// Process-scoped persistence backend for extensions that need stored thread history. pub(crate) thread_store: Arc, } @@ -57,6 +60,8 @@ where goal_service, environment_manager, executor_skill_provider, + git_attribution_base_url, + http_client_factory, thread_store: _thread_store, } = dependencies; let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); @@ -71,6 +76,12 @@ where |config: &Config| config.features.enabled(codex_features::Feature::Goals), ); } + codex_git_attribution::install( + &mut builder, + auth_manager.clone(), + git_attribution_base_url, + http_client_factory, + ); codex_guardian::install(&mut builder, guardian_agent_spawner); codex_memories_extension::install(&mut builder, codex_otel::global()); codex_mcp_extension::install(&mut builder); diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index b101a8bd0a01..b1ef983082a2 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -326,6 +326,8 @@ enabled = false goal_service: Arc::new(codex_goal_extension::GoalService::new()), environment_manager: Arc::clone(&environment_manager), executor_skill_provider: Arc::clone(&executor_skill_provider), + git_attribution_base_url: good_config.chatgpt_base_url.clone(), + http_client_factory: good_config.http_client_factory(), thread_store: Arc::clone(&thread_store), }, ), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 239dd6aed19e..69fb4f7210f6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -274,6 +274,8 @@ impl MessageProcessor { goal_service: Arc::clone(&goal_service), environment_manager: Arc::clone(&environment_manager_for_extensions), executor_skill_provider: Arc::clone(&executor_skill_provider), + git_attribution_base_url: config.chatgpt_base_url.clone(), + http_client_factory: config.http_client_factory(), thread_store: Arc::clone(&thread_store), }, ), diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 062d3a1e6ea6..63dcc844f765 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -130,6 +130,11 @@ use core_test_support::test_codex::test_env; use serde::de::DeserializeOwned; use tempfile::TempDir; use tokio::process::Command; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; use crate::json_logging::JsonLogCapture; use crate::local_websocket_exec_server::LocalWebsocketExecServer; @@ -150,6 +155,7 @@ pub struct TestAppServer { // Fields drop in declaration order. Tear down the delayed child before // removing an owned CODEX_HOME that may still be its cwd on Windows. _delayed_exec_server: Option<(LocalWebsocketExecServer, WebsocketDelayInterposer)>, + _attribution_settings_server: Option, _owned_codex_home: Option, } @@ -285,6 +291,7 @@ impl TestAppServer { auto_env: None, json_logs, _delayed_exec_server: None, + _attribution_settings_server: None, _owned_codex_home: None, }) } @@ -1879,6 +1886,35 @@ impl TestAppServerBuilder { ) } }; + let attribution_settings_server = if codex_home.join("auth.json").is_file() { + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + if config + .lines() + .any(|line| line.trim_start().starts_with("chatgpt_base_url")) + { + None + } else { + let settings_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "commit_attribution_enabled": false, + }))) + .mount(&settings_server) + .await; + std::fs::write( + &config_path, + format!( + "chatgpt_base_url = \"{}/backend-api\"\n{config}", + settings_server.uri() + ), + )?; + Some(settings_server) + } + } else { + None + }; let (auto_env, delayed_exec_server) = match environment { TestAppServerEnvironment::Auto => { let environments_toml = codex_home.join("environments.toml"); @@ -1985,6 +2021,7 @@ impl TestAppServerBuilder { app_server.auto_env = auto_env; app_server._owned_codex_home = owned_codex_home; app_server._delayed_exec_server = delayed_exec_server; + app_server._attribution_settings_server = attribution_settings_server; Ok(app_server) } } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 8b1d9585906e..12137c5b6107 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -84,6 +84,7 @@ struct CreateConfigTomlParams { forced_workspace_ids: Option>, requires_openai_auth: Option, base_url: Option, + chatgpt_base_url: Option, model_provider_id: Option, extra_provider_config: Option, } @@ -115,6 +116,10 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std: Some(false) => String::new(), None => String::new(), }; + let chatgpt_base_url_line = params + .chatgpt_base_url + .map(|url| format!("chatgpt_base_url = \"{url}\"\n")) + .unwrap_or_default(); let model_provider_id = params .model_provider_id .unwrap_or_else(|| "mock_provider".to_string()); @@ -137,6 +142,7 @@ stream_max_retries = 0 model = "mock-model" approval_policy = "never" sandbox_mode = "danger-full-access" +{chatgpt_base_url_line} {forced_line} {forced_workspace_line} @@ -532,6 +538,16 @@ async fn respond_to_refresh_request( Ok(()) } +async fn mount_disabled_attribution_settings(mock_server: &MockServer) { + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "commit_attribution_enabled": false, + }))) + .mount(mock_server) + .await; +} + #[tokio::test] // 401 response triggers account/chatgptAuthTokens/refresh and retries with new tokens. async fn external_auth_refreshes_on_unauthorized() -> Result<()> { @@ -542,6 +558,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { CreateConfigTomlParams { requires_openai_auth: Some(true), base_url: Some(format!("{}/v1", mock_server.uri())), + chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), ..Default::default() }, )?; @@ -560,6 +577,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { vec![unauthorized, responses::sse_response(success_sse)], ) .await; + mount_disabled_attribution_settings(&mock_server).await; let initial_access_token = encode_id_token( &ChatGptIdTokenClaims::new() @@ -655,6 +673,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { CreateConfigTomlParams { requires_openai_auth: Some(true), base_url: Some(format!("{}/v1", mock_server.uri())), + chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), ..Default::default() }, )?; @@ -665,6 +684,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { })); let _responses_mock = responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + mount_disabled_attribution_settings(&mock_server).await; let initial_access_token = encode_id_token( &ChatGptIdTokenClaims::new() @@ -764,6 +784,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()), requires_openai_auth: Some(true), base_url: Some(format!("{}/v1", mock_server.uri())), + chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), ..Default::default() }, )?; @@ -774,6 +795,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { })); let _responses_mock = responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + mount_disabled_attribution_settings(&mock_server).await; let initial_access_token = encode_id_token( &ChatGptIdTokenClaims::new() @@ -878,6 +900,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { CreateConfigTomlParams { requires_openai_auth: Some(true), base_url: Some(format!("{}/v1", mock_server.uri())), + chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), ..Default::default() }, )?; @@ -888,6 +911,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { })); let _responses_mock = responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + mount_disabled_attribution_settings(&mock_server).await; let initial_access_token = encode_id_token( &ChatGptIdTokenClaims::new() @@ -1110,7 +1134,6 @@ async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_chan AuthKeyringBackendKind::default(), )?; let expected_auth = load_file_auth(codex_home.path())?; - let expected_config = read_config_toml(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -1119,6 +1142,7 @@ async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_chan .with_args(&["-c", "model_provider=\"mock_provider\""]) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; + let expected_config = read_config_toml(codex_home.path())?; let request_id = mcp .send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2") @@ -1294,7 +1318,6 @@ async fn logout_aws_managed_bedrock_errors_without_changing_auth_or_config() -> AuthKeyringBackendKind::default(), )?; let expected_auth = load_file_auth(codex_home.path())?; - let expected_config = read_config_toml(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -1302,6 +1325,7 @@ async fn logout_aws_managed_bedrock_errors_without_changing_auth_or_config() -> .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; + let expected_config = read_config_toml(codex_home.path())?; let request_id = mcp.send_logout_account_request().await?; let error = timeout( DEFAULT_READ_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/git_attribution.rs b/codex-rs/app-server/tests/suite/v2/git_attribution.rs new file mode 100644 index 000000000000..96d8936c3944 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/git_attribution.rs @@ -0,0 +1,379 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::TestAppServer; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadRollbackResponse; +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_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const COMMIT_ATTRIBUTION: &str = "Co-authored-by: Codex "; +const PR_ATTRIBUTION: &str = "Generated with Codex."; +const ATTRIBUTION_DISABLED: &str = "attribution is disabled for the current workspace"; +const LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS: &str = "\ +When you write or edit a git commit message, ensure the message ends with this trailer exactly once: +Co-authored-by: Codex + +Rules: +- Keep existing trailers and append this trailer at the end if missing. +- Do not duplicate this trailer if it already exists. +- Keep one blank line between the commit body and trailer block."; + +#[tokio::test] +async fn git_attribution_follows_authenticated_workspace_policy() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let settings_server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + [ + "Unavailable", + "Recovered", + "Cached", + "After switch", + "After rollback", + ] + .into_iter() + .map(create_final_assistant_message_sse_response) + .collect::>>()?, + ) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + let enabled_settings_requests = Arc::new(AtomicUsize::new(0)); + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .and(header("chatgpt-account-id", "workspace-enabled")) + .respond_with({ + let enabled_settings_requests = enabled_settings_requests.clone(); + move |_request: &wiremock::Request| { + if enabled_settings_requests.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(503) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "commit_attribution_enabled": true, + })) + } + } + }) + .expect(3) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .and(header("chatgpt-account-id", "workspace-disabled")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "commit_attribution_enabled": false, + }))) + .expect(1) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &format!("{}/backend-api", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("workspace-enabled") + .plan_type("enterprise"), + AuthCredentialsStoreMode::File, + )?; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .with_env_overrides(&[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)]) + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams { + config: Some(HashMap::from([( + "chatgpt_base_url".to_string(), + json!(format!("{}/backend-api", settings_server.uri())), + )])), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = read_response(&mut app_server, request_id).await?; + run_turn(&mut app_server, &thread.id, "First turn").await?; + run_turn(&mut app_server, &thread.id, "Second turn").await?; + run_turn(&mut app_server, &thread.id, "Third turn").await?; + + let request_id = app_server + .send_chatgpt_auth_tokens_login_request( + "e30.e30.c2ln".to_string(), + "workspace-disabled".to_string(), + Some("enterprise".to_string()), + ) + .await?; + let _: LoginAccountResponse = read_response(&mut app_server, request_id).await?; + run_turn(&mut app_server, &thread.id, "Turn after workspace switch").await?; + + let request_id = app_server + .send_thread_rollback_request(ThreadRollbackParams { + thread_id: thread.id.clone(), + num_turns: 1, + }) + .await?; + let _: ThreadRollbackResponse = read_response(&mut app_server, request_id).await?; + + let request_id = app_server + .send_chatgpt_auth_tokens_login_request( + "e30.e30.c2ln".to_string(), + "workspace-enabled".to_string(), + Some("enterprise".to_string()), + ) + .await?; + let _: LoginAccountResponse = read_response(&mut app_server, request_id).await?; + run_turn(&mut app_server, &thread.id, "Turn after rollback").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 5); + for (request, expected) in requests + .into_iter() + .zip([(0, 0), (1, 0), (1, 0), (1, 1), (1, 0)]) + { + let developer_text = request.message_input_texts("developer").join("\n"); + assert_eq!( + ( + developer_text.matches(COMMIT_ATTRIBUTION).count(), + developer_text.matches(PR_ATTRIBUTION).count(), + developer_text.matches(ATTRIBUTION_DISABLED).count(), + ), + (expected.0, expected.0, expected.1) + ); + } + server.verify().await; + assert!( + settings_server + .received_requests() + .await + .context("failed to fetch thread-override requests")? + .iter() + .all(|request| request.url.path() != "/backend-api/wham/settings/user"), + "attribution settings must use the process-level base URL" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cold_resume_replaces_legacy_attribution_without_duplication() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + ["Initial", "Resumed", "Resumed again"] + .into_iter() + .map(create_final_assistant_message_sse_response) + .collect::>>()?, + ) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + let settings_requests = Arc::new(AtomicUsize::new(0)); + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .and(header("chatgpt-account-id", "workspace-resume")) + .respond_with({ + let settings_requests = Arc::clone(&settings_requests); + move |_request: &wiremock::Request| { + ResponseTemplate::new(200).set_body_json(json!({ + "commit_attribution_enabled": settings_requests + .fetch_add(1, Ordering::SeqCst) + == 0, + })) + } + }) + .expect(2) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &format!("{}/backend-api", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("workspace-resume") + .plan_type("enterprise"), + AuthCredentialsStoreMode::File, + )?; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .with_env_overrides(&[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)]) + .without_auto_env() + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + let request_id = app_server + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let ThreadStartResponse { thread, .. } = read_response(&mut app_server, request_id).await?; + run_turn(&mut app_server, &thread.id, "persist enabled attribution").await?; + let rollout_path = thread + .path + .context("initial thread should have a rollout path")?; + let status = timeout(DEFAULT_READ_TIMEOUT, app_server.shutdown_gracefully()).await??; + anyhow::ensure!( + status.success(), + "initial app-server did not exit successfully" + ); + replace_attribution_fragment_with_legacy(&rollout_path)?; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .with_env_overrides(&[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)]) + .without_auto_env() + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + let request_id = app_server + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let ThreadResumeResponse { thread, .. } = read_response(&mut app_server, request_id).await?; + run_turn(&mut app_server, &thread.id, "resume disabled attribution").await?; + run_turn(&mut app_server, &thread.id, "continue disabled attribution").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let initial_text = requests[0].message_input_texts("developer").join("\n"); + assert_eq!(initial_text.matches(COMMIT_ATTRIBUTION).count(), 1); + assert_eq!(initial_text.matches(PR_ATTRIBUTION).count(), 1); + assert_eq!(initial_text.matches(ATTRIBUTION_DISABLED).count(), 0); + for request in &requests[1..] { + let developer_text = request.message_input_texts("developer").join("\n"); + assert_eq!(developer_text.matches(COMMIT_ATTRIBUTION).count(), 1); + assert_eq!(developer_text.matches(PR_ATTRIBUTION).count(), 0); + assert_eq!(developer_text.matches(ATTRIBUTION_DISABLED).count(), 1); + } + server.verify().await; + Ok(()) +} + +fn replace_attribution_fragment_with_legacy(rollout_path: &Path) -> Result<()> { + let rollout = std::fs::read_to_string(rollout_path)?; + let mut replaced = false; + let mut removed_saved_attribution = false; + let lines = rollout + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let mut line = serde_json::from_str::(line)?; + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = + &mut line.item + && role == "developer" + { + for item in content { + if let ContentItem::InputText { text } = item + && text.contains("") + { + *text = LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS.to_string(); + replaced = true; + } + } + } + if let RolloutItem::WorldState(world_state) = &mut line.item + && let Some(state) = world_state.state.as_object_mut() + && state.remove("git_attribution").is_some() + { + removed_saved_attribution = true; + } + serde_json::to_string(&line) + }) + .collect::, _>>()?; + anyhow::ensure!(replaced, "rollout did not contain git attribution context"); + anyhow::ensure!( + removed_saved_attribution, + "rollout did not contain saved git attribution state" + ); + std::fs::write(rollout_path, format!("{}\n", lines.join("\n")))?; + Ok(()) +} + +async fn read_response( + app_server: &mut TestAppServer, + request_id: i64, +) -> Result { + let response = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn run_turn(app_server: &mut TestAppServer, thread_id: &str, text: &str) -> Result<()> { + timeout( + DEFAULT_READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }), + ) + .await??; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 6566f0459e90..ca571ae65c3a 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -27,6 +27,7 @@ mod experimental_api; mod experimental_feature_list; mod external_agent_config; mod fs; +mod git_attribution; mod hooks_list; mod host_skills; mod imagegen_extension; diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 46a74782abaa..14e9b51980e1 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -38,7 +38,9 @@ codex-http-client = { workspace = true } codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } +codex-extension-api = { workspace = true } codex-features = { workspace = true } +codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } codex-install-context = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8dd1e118e2ed..62f158f7602a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1999,10 +1999,20 @@ async fn run_debug_prompt_input_command( let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( config.codex_home.clone(), )); + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new(); + codex_git_attribution::install( + &mut extensions, + auth_manager, + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let prompt_input = codex_core::build_prompt_input( config, input, /*state_db*/ None, + Arc::new(extensions.build()), user_instructions_provider, ) .await?; diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index 4f37f41418c9..0f95bef5e0a2 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -1,7 +1,13 @@ use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::CLIENT_ID; use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use predicates::str::contains; @@ -12,6 +18,7 @@ use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -74,6 +81,56 @@ fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn debug_prompt_input_follows_authenticated_attribution_setting() -> Result<()> { + let server = MockServer::start().await; + let request_count = Arc::new(AtomicUsize::new(0)); + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .and(header("chatgpt-account-id", "workspace-123")) + .respond_with(move |_request: &wiremock::Request| { + ResponseTemplate::new(200).set_body_json(json!({ + "commit_attribution_enabled": request_count.fetch_add(1, Ordering::SeqCst) == 0, + })) + }) + .expect(2) + .mount(&server) + .await; + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{}/backend-api\"\n", + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("workspace-123") + .plan_type("enterprise"), + AuthCredentialsStoreMode::File, + )?; + for enabled in [true, false] { + let output = codex_command(codex_home.path())? + .env("NO_PROXY", "127.0.0.1,localhost") + .env("no_proxy", "127.0.0.1,localhost") + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("OPENAI_API_KEY") + .args(["debug", "prompt-input"]) + .output()?; + assert!(output.status.success()); + let prompt = String::from_utf8(output.stdout)?; + assert_eq!( + prompt.contains("Co-authored-by: Codex "), + enabled + ); + assert!(!prompt.contains("attribution is disabled for the current workspace")); + } + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn device_login_revokes_existing_auth_before_requesting_new_tokens() -> Result<()> { let server = MockServer::start().await; diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index c92b79a7d921..96e15ea31304 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -42,6 +42,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ COLLABORATION_MODE_OPEN_TAG, MULTI_AGENT_MODE_OPEN_TAG, ENVIRONMENTS_INSTRUCTIONS_OPEN_TAG, + "", PLUGINS_INSTRUCTIONS_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 7670977c9ae9..511af1c36407 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; +use codex_extension_api::ExtensionRegistry; use codex_extension_api::UserInstructionsProvider; use codex_login::AuthManager; use codex_protocol::error::CodexErr; @@ -20,7 +21,6 @@ use crate::state_db_bridge::StateDbHandle; use crate::thread_manager::StartThreadOptions; use crate::thread_manager::ThreadManager; use crate::thread_manager::thread_store_from_config; -use codex_extension_api::empty_extension_registry; /// Build the model-visible `input` list for a single debug turn. #[doc(hidden)] @@ -28,6 +28,7 @@ pub async fn build_prompt_input( mut config: Config, input: Vec, state_db: Option, + extensions: Arc>, user_instructions_provider: Arc, ) -> CodexResult> { config.ephemeral = true; @@ -56,7 +57,7 @@ pub async fn build_prompt_input( .await .map_err(|err| CodexErr::Fatal(err.to_string()))?, ), - empty_extension_registry(), + extensions, user_instructions_provider, /*analytics_events_client*/ None, thread_store, diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 3d75f8f18577..6248dfa87d0d 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -4,6 +4,7 @@ use anyhow::Result; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; +use codex_extension_api::ExtensionRegistryBuilder; use codex_home::CodexHomeUserInstructionsProvider; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -32,7 +33,6 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( config.codex_home.clone(), )); - let input = build_prompt_input( config, vec![UserInput::Text { @@ -40,6 +40,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text_elements: Vec::new(), }], /*state_db*/ None, + Arc::new(ExtensionRegistryBuilder::new().build()), user_instructions_provider, ) .await?; @@ -74,6 +75,5 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text.contains(TEST_INSTRUCTIONS) }) })); - Ok(()) } diff --git a/codex-rs/ext/git-attribution/Cargo.toml b/codex-rs/ext/git-attribution/Cargo.toml index 5650e1d2a6be..e96d0dbcd577 100644 --- a/codex-rs/ext/git-attribution/Cargo.toml +++ b/codex-rs/ext/git-attribution/Cargo.toml @@ -21,7 +21,5 @@ serde_json = { workspace = true } tokio = { workspace = true, features = ["time"] } [dev-dependencies] -codex-protocol = { workspace = true } -pretty_assertions = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } wiremock = { workspace = true } diff --git a/codex-rs/ext/git-attribution/src/git_attribution_tests.rs b/codex-rs/ext/git-attribution/src/git_attribution_tests.rs index 8be67add6d38..0123f5b4e85d 100644 --- a/codex-rs/ext/git-attribution/src/git_attribution_tests.rs +++ b/codex-rs/ext/git-attribution/src/git_attribution_tests.rs @@ -3,12 +3,6 @@ use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use std::time::Duration; -use codex_extension_api::ExtensionData; -use codex_extension_api::ExtensionRegistry; -use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::PreviousWorldStateSection; -use codex_extension_api::WorldStateContributionInput; -use codex_extension_api::WorldStateSectionContribution; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use codex_login::AuthManager; @@ -16,9 +10,6 @@ use codex_login::CodexAuth; use codex_login::ExternalAuth; use codex_login::ExternalAuthFuture; use codex_login::ExternalAuthRefreshContext; -use codex_protocol::ThreadId; -use pretty_assertions::assert_eq; -use serde_json::Value; use tokio::sync::Notify; use wiremock::Mock; use wiremock::MockServer; @@ -26,32 +17,7 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -use super::install; use super::policy::resolve_attribution_policy; -use super::world_state::DISABLED_INSTRUCTIONS; -use super::world_state::ENABLED_INSTRUCTIONS; -use super::world_state::LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS; - -async fn contribute( - registry: &ExtensionRegistry, - thread_store: &ExtensionData, -) -> WorldStateSectionContribution { - let session_store = ExtensionData::new("session"); - let turn_store = ExtensionData::new("turn"); - registry.context_contributors()[0] - .contribute_world_state(WorldStateContributionInput { - thread_id: ThreadId::new(), - turn_id: "turn", - environments: &[], - ready_selected_capability_roots: &[], - executor_capability_discovery: None, - session_store: &session_store, - thread_store, - turn_store: &turn_store, - }) - .await - .remove(0) -} fn enterprise_auth_manager() -> Arc { AuthManager::from_auth_for_testing(enterprise_auth("workspace-123")) @@ -66,15 +32,6 @@ fn enterprise_auth(account_id: &str) -> CodexAuth { .expect("fake ChatGPT auth should parse") } -async fn mount_settings(server: &MockServer, response: ResponseTemplate) { - Mock::given(method("GET")) - .and(path("/backend-api/wham/settings/user")) - .respond_with(response) - .expect(1) - .mount(server) - .await; -} - struct StaticExternalAuth(CodexAuth); impl ExternalAuth for StaticExternalAuth { @@ -94,116 +51,6 @@ async fn set_auth(auth_manager: &AuthManager, account_id: &str) { .expect("auth refresh should succeed"); } -#[tokio::test] -async fn installed_contributor_composes_policy_changes() { - let server = MockServer::start().await; - let base_url = format!("{}/backend-api", server.uri()); - let api_key = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - assert!( - !resolve_attribution_policy(&api_key, &base_url, &http_client_factory()) - .await - .expect("API key policy should not time out") - .expect("API key policy should resolve") - .enabled - ); - let auth_manager = enterprise_auth_manager(); - let mut builder = ExtensionRegistryBuilder::::new(); - install( - &mut builder, - auth_manager.clone(), - base_url.clone(), - http_client_factory(), - ); - let registry = builder.build(); - let thread_store = ExtensionData::new("thread"); - mount_settings( - &server, - ResponseTemplate::new(200) - .set_delay(Duration::from_secs(1)) - .set_body_json(serde_json::json!({"commit_attribution_enabled": true})), - ) - .await; - let unavailable = contribute(®istry, &thread_store).await; - assert_eq!(unavailable.snapshot(), &Value::Bool(false)); - assert!( - unavailable - .render_diff(PreviousWorldStateSection::Absent) - .is_none() - ); - assert!(!unavailable.has_retained_fragment_matcher()); - assert!( - unavailable.matches_legacy_fragment("developer", LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS) - ); - assert_eq!( - unavailable - .render_diff(PreviousWorldStateSection::Unknown) - .map(|fragment| fragment.body().to_string()), - Some(DISABLED_INSTRUCTIONS.to_string()) - ); - assert_eq!( - contribute(®istry, &thread_store).await.snapshot(), - &Value::Bool(false) - ); - server.verify().await; - server.reset().await; - mount_settings(&server, ResponseTemplate::new(503)).await; - set_auth(auth_manager.as_ref(), "workspace-456").await; - let failed = contribute(®istry, &thread_store).await; - assert_eq!(failed.snapshot(), &Value::Bool(false)); - assert!( - failed - .render_diff(PreviousWorldStateSection::Absent) - .is_none() - ); - server.verify().await; - server.reset().await; - mount_settings( - &server, - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"commit_attribution_enabled": true})), - ) - .await; - set_auth(auth_manager.as_ref(), "workspace-789").await; - let enabled = contribute(®istry, &thread_store).await; - let enabled_fragment = enabled - .render_diff(PreviousWorldStateSection::Known(&Value::Bool(false))) - .expect("enabled policy should replace disabled policy"); - assert_eq!(enabled_fragment.body(), ENABLED_INSTRUCTIONS); - let (start, end) = enabled_fragment.markers(); - let rendered = format!("{start}{}{end}", enabled_fragment.body()); - assert!(enabled.matches_legacy_fragment(enabled_fragment.role(), &rendered)); - assert!(enabled.matches_retained_fragment(enabled_fragment.role(), &rendered)); - assert!(!enabled.matches_legacy_fragment("developer", LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS)); - assert!( - contribute(®istry, &thread_store) - .await - .render_diff(PreviousWorldStateSection::Known(&Value::Bool(true))) - .is_none() - ); - server.verify().await; - server.reset().await; - mount_settings( - &server, - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"commit_attribution_enabled": false})), - ) - .await; - set_auth(auth_manager.as_ref(), "workspace-disabled").await; - let disabled = contribute(®istry, &thread_store).await; - assert_eq!( - disabled - .render_diff(PreviousWorldStateSection::Known(&Value::Bool(true))) - .map(|fragment| fragment.body().to_string()), - Some(DISABLED_INSTRUCTIONS.to_string()) - ); - assert!( - disabled - .render_diff(PreviousWorldStateSection::Absent) - .is_none() - ); - server.verify().await; -} - #[tokio::test] async fn policy_resolution_recovers_after_unauthorized() { let server = MockServer::start().await; diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 40cd510a6a11..9c4df8629490 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -25,6 +25,7 @@ codex-home = { workspace = true } codex-image-generation-extension = { workspace = true } codex-exec-server = { workspace = true } codex-extension-api = { workspace = true } +codex-git-attribution = { workspace = true } codex-login = { workspace = true } codex-protocol = { workspace = true } codex-utils-cli = { workspace = true } @@ -45,6 +46,7 @@ tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] +app_test_support = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-shell-command = { workspace = true } core_test_support = { workspace = true } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index cf01df681445..2a13a4d61f6f 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -67,6 +67,12 @@ impl MessageProcessor { config.codex_home.clone(), )); let mut extensions = ExtensionRegistryBuilder::::new(); + codex_git_attribution::install( + &mut extensions, + auth_manager.clone(), + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); codex_image_generation_extension::install( &mut extensions, auth_manager.clone(), diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 0b9d43f90869..75db8b0e6c2e 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -3,6 +3,9 @@ use std::env; use std::path::Path; use std::path::PathBuf; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_mcp_server::CodexToolCallParam; use codex_mcp_server::ExecApprovalElicitRequestParams; @@ -19,7 +22,11 @@ use rmcp::model::RequestId; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; use core_test_support::skip_if_no_network; use mcp_test_support::McpProcess; @@ -366,17 +373,39 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { let server = create_mock_responses_server(vec![create_final_assistant_message_sse_response("Enjoy!")?]) .await; + let caller_server = MockServer::start().await; // Run `codex mcp` with a specific config.toml. let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp_process = McpProcess::new(codex_home.path()).await?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token").account_id("workspace-123"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "commit_attribution_enabled": true, + }))) + .expect(1) + .mount(&server) + .await; + let mut mcp_process = McpProcess::new_with_env( + codex_home.path(), + &[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)], + ) + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; // Send a "codex" tool request, which should hit the responses endpoint. let codex_request_id = mcp_process .send_codex_tool_call(CodexToolCallParam { prompt: "How are you?".to_string(), + config: Some(HashMap::from([( + "chatgpt_base_url".to_string(), + json!(format!("{}/backend-api", caller_server.uri())), + )])), base_instructions: Some("You are a helpful assistant.".to_string()), developer_instructions: Some("Foreshadow upcoming tool calls.".to_string()), ..Default::default() @@ -412,12 +441,15 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { ); let requests = server.received_requests().await.unwrap(); - let request = requests[0].body_json::()?; + let request = requests + .iter() + .find(|request| request.url.path() == "/v1/responses") + .expect("mock model request should be recorded") + .body_json::()?; let instructions = request["instructions"] .as_str() .expect("responses request should include instructions"); assert!(instructions.starts_with("You are a helpful assistant.")); - let developer_messages: Vec<&serde_json::Value> = request["input"] .as_array() .expect("responses request should include input items") @@ -431,6 +463,14 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { .filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text")) .filter_map(|span| span.get("text").and_then(serde_json::Value::as_str)) .collect(); + let developer_text = developer_contents.join("\n"); + assert_eq!( + developer_text + .matches("Co-authored-by: Codex ") + .count(), + 1 + ); + assert_eq!(developer_text.matches("Generated with Codex.").count(), 1); assert!( developer_contents .iter() @@ -441,6 +481,13 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { developer_contents.contains(&"Foreshadow upcoming tool calls."), "expected developer instructions in developer messages, got {developer_contents:?}" ); + let caller_requests = caller_server.received_requests().await.unwrap(); + assert!( + caller_requests + .iter() + .all(|request| request.url.path() != "/backend-api/wham/settings/user"), + "attribution settings must use the process-level base URL" + ); Ok(()) } @@ -513,6 +560,8 @@ approval_policy = "untrusted" sandbox_policy = "workspace-write" model_provider = "mock_provider" +chatgpt_base_url = "{server_uri}/backend-api" +cli_auth_credentials_store = "file" [model_providers.mock_provider] name = "Mock provider for test"