From f158b31db59c2f9f11178d06a2a5c49707e8689b Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 11:21:56 -0700 Subject: [PATCH 01/53] test: generalize exec-server fixture (#31422) ## Why Remote-executor integration tests need one host-agnostic exec-server fixture target instead of a Windows-only wrapper. ## What - rename the testing binary target to exec-server - make the fixture source and target host-agnostic - update Windows remote-executor test wiring to use the shared target ## Validation - bazel build //codex-rs/exec-server/testing:exec-server - bazel cquery --config=ci-windows-cross 'set(//codex-rs/exec-server/testing:exec-server //codex-rs/core/tests/remote_env_windows:smoke-test)' ## Stack 1. [#31422 test: generalize exec-server fixture](https://github.com/openai/codex/pull/31422) 2. [#31425 test: add TestAppServer builder](https://github.com/openai/codex/pull/31425) 3. [#31427 test: add delayed exec-server transport](https://github.com/openai/codex/pull/31427) 4. [#31295 bench: add cold skill load macrobenchmark](https://github.com/openai/codex/pull/31295) 5. [#31428 bench: add e2e benchmark entrypoints](https://github.com/openai/codex/pull/31428) 6. [#31429 ci: smoke Bazel e2e benchmarks](https://github.com/openai/codex/pull/31429) --- .../core/tests/remote_env_windows/BUILD.bazel | 2 +- codex-rs/exec-server/testing/BUILD.bazel | 10 ++++------ codex-rs/exec-server/testing/exec_server.rs | 14 ++++++++++++++ .../exec-server/testing/windows_exec_server.rs | 18 ------------------ defs.bzl | 2 +- 5 files changed, 20 insertions(+), 26 deletions(-) create mode 100644 codex-rs/exec-server/testing/exec_server.rs delete mode 100644 codex-rs/exec-server/testing/windows_exec_server.rs diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 1f2465ffcb..6ad412da2b 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -7,7 +7,7 @@ wine_rust_test( crate_name = "remote_env_windows_test", crate_root = "remote_env_windows_test.rs", windows_binaries = { - "wine-windows-exec-server": "//codex-rs/exec-server/testing:windows-exec-server", + "wine-windows-exec-server": "//codex-rs/exec-server/testing:exec-server", }, deps = [ "//codex-rs/core/tests/common", diff --git a/codex-rs/exec-server/testing/BUILD.bazel b/codex-rs/exec-server/testing/BUILD.bazel index ac4818f55e..2e2cc220d4 100644 --- a/codex-rs/exec-server/testing/BUILD.bazel +++ b/codex-rs/exec-server/testing/BUILD.bazel @@ -32,13 +32,11 @@ rust_binary( ) rust_binary( - name = "windows-exec-server", + name = "exec-server", testonly = True, - srcs = ["windows_exec_server.rs"], - crate_name = "windows_exec_server", - crate_root = "windows_exec_server.rs", - tags = ["manual"], - target_compatible_with = ["@platforms//os:windows"], + srcs = ["exec_server.rs"], + crate_name = "exec_server", + crate_root = "exec_server.rs", visibility = ["//visibility:public"], deps = [ "//codex-rs/exec-server", diff --git a/codex-rs/exec-server/testing/exec_server.rs b/codex-rs/exec-server/testing/exec_server.rs new file mode 100644 index 0000000000..dbef4a4899 --- /dev/null +++ b/codex-rs/exec-server/testing/exec_server.rs @@ -0,0 +1,14 @@ +//! Minimal exec-server fixture for Bazel-only integration tests. +//! +//! Linking only exec-server avoids depending on the full Codex CLI binary +//! when a test only needs a WebSocket executor endpoint. + +use codex_exec_server::ExecServerRuntimePaths; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let current_exe = std::env::current_exe()?; + let runtime_paths = + ExecServerRuntimePaths::new(current_exe, /*codex_linux_sandbox_exe*/ None)?; + codex_exec_server::run_main("ws://127.0.0.1:0", runtime_paths).await +} diff --git a/codex-rs/exec-server/testing/windows_exec_server.rs b/codex-rs/exec-server/testing/windows_exec_server.rs deleted file mode 100644 index 9efc5286fc..0000000000 --- a/codex-rs/exec-server/testing/windows_exec_server.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Minimal Windows exec-server fixture for cross-platform tests. -//! -//! Keeping this wrapper separate avoids depending on the full Codex binary's -//! Windows cross-build, which is not yet supported by the Bazel graph. Linking -//! only the exec-server also makes the Wine test substantially faster to -//! iterate on. - -use codex_exec_server::ExecServerRuntimePaths; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let current_exe = std::env::current_exe()?; - // This fixture is always a Windows executable, so it neither invokes nor - // needs the separate Linux sandbox binary. - let runtime_paths = - ExecServerRuntimePaths::new(current_exe, /*codex_linux_sandbox_exe*/ None)?; - codex_exec_server::run_main("ws://127.0.0.1:0", runtime_paths).await -} diff --git a/defs.bzl b/defs.bzl index c8fd35e039..8532a223eb 100644 --- a/defs.bzl +++ b/defs.bzl @@ -538,7 +538,7 @@ def codex_rust_crate( wine_exec_server = wine_test_name + "-windows-exec-server" foreign_platform_binary( name = wine_exec_server, - binary = "//codex-rs/exec-server/testing:windows-exec-server", + binary = "//codex-rs/exec-server/testing:exec-server", extra_rustc_flags = WINDOWS_GNULLVM_RUSTC_LINK_FLAGS, platform = "//:windows_x86_64_gnullvm", tags = ["manual"], From ff06ab7172f4a442e3cc0314b1178f8ea3c5ccac Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 7 Jul 2026 11:48:27 -0700 Subject: [PATCH 02/53] [codex] Enable auth elicitation by default (#28772) ## Summary - enable `auth_elicitation` by default - promote the feature to `Stable`, as default-enabled features must be stable - update the feature regression test to cover the new lifecycle state and default ## Impact Auth elicitation is now available without requiring users or clients to opt in through configuration. ## Testing - `just test -p codex-features` (52 passed) --- codex-rs/core/src/config/config_tests.rs | 14 +- codex-rs/core/src/mcp_tool_call_tests.rs | 16 +- .../core/tests/suite/mcp_auth_elicitation.rs | 215 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/features/src/lib.rs | 4 +- codex-rs/features/src/tests.rs | 6 +- 6 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 codex-rs/core/tests/suite/mcp_auth_elicitation.rs diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 40ebc64a0b..0c8d0899a4 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6039,13 +6039,6 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std:: .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); - let mcp_config = config.to_mcp_config(&plugins_manager).await; - assert_eq!( - mcp_config.client_elicitation_capability, - ElicitationCapability::default() - ); - - let _ = config.features.enable(Feature::AuthElicitation); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( mcp_config.client_elicitation_capability, @@ -6055,6 +6048,13 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std:: } ); + let _ = config.features.disable(Feature::AuthElicitation); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + assert_eq!( + mcp_config.client_elicitation_capability, + ElicitationCapability::default() + ); + Ok(()) } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 336eecbfdf..277caa482d 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1472,7 +1472,11 @@ async fn host_owned_codex_apps_manager( #[tokio::test] async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() { - let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + let mut features = Features::with_defaults(); + features.disable(Feature::AuthElicitation); + let mutable_turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + Arc::make_mut(&mut mutable_turn_context.config).features = ManagedFeatures::from(features); let manager = host_owned_codex_apps_manager(&session, &turn_context).await; let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); @@ -1585,16 +1589,10 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu } #[tokio::test] -async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() { - let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; +async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation() { + let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; let manager = host_owned_codex_apps_manager(&session, &turn_context).await; *session.active_turn.lock().await = Some(ActiveTurn::default()); - let mut features = Features::with_defaults(); - features.enable(Feature::AuthElicitation); - { - let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); - Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); - } let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); diff --git a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs new file mode 100644 index 0000000000..735006d495 --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs @@ -0,0 +1,215 @@ +#![allow(clippy::unwrap_used)] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ElicitationAction; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::PathExt; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::Request; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_partial_json; +use wiremock::matchers::method; +use wiremock::matchers::path_regex; + +#[derive(Clone, Copy)] +struct AuthFailureResponder; + +impl Respond for AuthFailureResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = + serde_json::from_slice(&request.body).expect("tools/call request should be valid JSON"); + let id = body.get("id").cloned().unwrap_or(Value::Null); + + ResponseTemplate::new(/*status*/ 200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ + "type": "text", + "text": "Connector reauthentication required", + }], + "isError": true, + "_meta": { + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "auth_reason": "reauthentication_required", + "connector_id": "calendar", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", + }, + }, + }, + }, + })) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_apps_auth_failure_requests_elicitation_by_default() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + Mock::given(method("POST")) + .and(path_regex("^/api/codex/apps/?$")) + .and(body_partial_json(json!({ + "method": "tools/call", + "params": { + "name": "calendar_create_event", + }, + }))) + .respond_with(AuthFailureResponder) + .with_priority(/*p*/ 1) + .mount(&server) + .await; + + let call_id = "calendar-auth-call"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &json!({ + "title": "Lunch", + "starts_at": "2026-06-18T12:00:00Z", + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = + search_capable_apps_builder(apps_server.chatgpt_base_url).with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + let user_config_path = config.codex_home.join("config.toml").abs(); + let user_config = toml::from_str( + r#" +[apps.calendar] +default_tools_approval_mode = "auto" +"#, + ) + .expect("apps config should parse"); + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&user_config_path, user_config); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Use [$calendar](app://calendar) to create a calendar event.".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ElicitationRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await + else { + panic!("default auth elicitation should prompt before completing the turn"); + }; + + assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!( + request.id, + codex_protocol::mcp::RequestId::String(format!("codex_apps_auth_{call_id}")) + ); + assert_eq!( + request.request, + ElicitationRequest::Url { + meta: Some(json!({ + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "connector_id": "calendar", + "connector_name": "Calendar", + "install_url": "https://chatgpt.com/apps/calendar/calendar", + "auth_reason": "reauthentication_required", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", + }, + }, + })), + message: "Reconnect Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/calendar/calendar".to_string(), + elicitation_id: format!("codex_apps_auth_{call_id}"), + } + ); + + test.codex + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: ElicitationAction::Accept, + content: None, + meta: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output = requests[1] + .function_call_output_text(call_id) + .expect("follow-up request should contain the auth elicitation result"); + assert!(output.contains( + "Authentication for Calendar was requested and accepted. Retry this tool call now." + )); + assert!(!output.contains("Connector reauthentication required")); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 3e27463370..9b22222d7c 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -65,6 +65,7 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +mod mcp_auth_elicitation; #[cfg(unix)] mod mcp_refresh_cleanup; mod mcp_tool_exposure; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index ce5ca459b4..166dc912fd 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -1273,8 +1273,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::AuthElicitation, key: "auth_elicitation", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Personality, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 13abd9ecd2..7291c4e1b1 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -316,9 +316,9 @@ fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { } #[test] -fn auth_elicitation_is_under_development() { - assert_eq!(Feature::AuthElicitation.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::AuthElicitation.default_enabled(), false); +fn auth_elicitation_is_stable_and_enabled_by_default() { + assert_eq!(Feature::AuthElicitation.stage(), Stage::Stable); + assert_eq!(Feature::AuthElicitation.default_enabled(), true); assert_eq!( feature_for_key("auth_elicitation"), Some(Feature::AuthElicitation) From 1fd0858e865d213106bba14251d06a9b9343ca37 Mon Sep 17 00:00:00 2001 From: rafael-jac Date: Tue, 7 Jul 2026 15:48:05 -0400 Subject: [PATCH 03/53] [login] support hosted success redirects (#28745) ## What Adds an optional hosted login-success redirect path for app-server login requests. - Keeps the existing localhost success page as the default. - Lets app-server callers opt into a hosted success page with an optional protocol field. - Persists credentials before redirecting to the hosted success page. - Keeps org setup and existing CLI/device-code login flows on the local success page. - Accepts an optional typed `appBrand` value and forwards it to the hosted page as `app_brand` so web can select the correct asset. - Generates the app-server protocol schema updates for the new optional fields. ## Why This supports the hosted Codex login success page rollout without changing existing login behavior by default. The Codex Apps frontend can gate the opt-in with Statsig after the hosted web page. ## Rollout safety - Old callers omit the new field and continue using localhost. - New callers talking to old app-server builds remain safe because the Codex Apps side treats the field as optional and defaults the flag off. - Missing brand values default to Codex. - The hosted redirect always uses the app-login source so the hosted page can reopen Codex; the existing streamlined-login visual flag remains separate. ## Validation - `just fmt` - `just fix -p codex-login -p codex-app-server-protocol -p codex-app-server -p codex-app-server-test-client -p codex-tui` - `just test -p codex-login` - `just test -p codex-app-server-protocol` - `just write-app-server-schema` - `git diff HEAD --check` The focused login and protocol run passed all 380 tests. I also started the broader `just test -p codex-app-server`; it compiled successfully, then many tests failed on this machine because spawned test servers tried to use the ambient `/Users/rafaelj/.codex/sqlite` state DB, which is read-only in this sandbox. I stopped that run after confirming the failures shared that environment issue. --- .../schema/json/ClientRequest.json | 21 ++ .../codex_app_server_protocol.schemas.json | 21 ++ .../codex_app_server_protocol.v2.schemas.json | 21 ++ .../schema/json/v2/LoginAccountParams.json | 23 ++ .../typescript/v2/LoginAccountParams.ts | 3 +- .../schema/typescript/v2/LoginAppBrand.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 34 ++- .../src/protocol/v2/account.rs | 15 ++ codex-rs/app-server-test-client/src/lib.rs | 2 + codex-rs/app-server/README.md | 5 + codex-rs/app-server/src/request_processors.rs | 4 + .../request_processors/account_processor.rs | 47 +++- .../tests/common/test_app_server.rs | 18 +- codex-rs/app-server/tests/suite/v2/account.rs | 76 ++++++- codex-rs/login/src/lib.rs | 4 + codex-rs/login/src/server.rs | 201 ++++++------------ codex-rs/login/src/success_page.rs | 143 +++++++++++++ codex-rs/login/src/success_page_tests.rs | 120 +++++++++++ .../login/tests/suite/login_server_e2e.rs | 69 +++++- codex-rs/tui/src/onboarding/auth.rs | 2 + 21 files changed, 680 insertions(+), 155 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts create mode 100644 codex-rs/login/src/success_page.rs create mode 100644 codex-rs/login/src/success_page_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2f39f821b6..d391cac165 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1463,6 +1463,17 @@ }, { "properties": { + "appBrand": { + "anyOf": [ + { + "$ref": "#/definitions/LoginAppBrand" + }, + { + "type": "null" + } + ], + "default": null + }, "codexStreamlinedLogin": { "type": "boolean" }, @@ -1472,6 +1483,9 @@ ], "title": "ChatgptLoginAccountParamsType", "type": "string" + }, + "useHostedLoginSuccessPage": { + "type": "boolean" } }, "required": [ @@ -1532,6 +1546,13 @@ } ] }, + "LoginAppBrand": { + "enum": [ + "codex", + "chatgpt" + ], + "type": "string" + }, "MarketplaceAddParams": { "properties": { "refName": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 53bd7cef4e..1102135a71 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11504,6 +11504,17 @@ }, { "properties": { + "appBrand": { + "anyOf": [ + { + "$ref": "#/definitions/v2/LoginAppBrand" + }, + { + "type": "null" + } + ], + "default": null + }, "codexStreamlinedLogin": { "type": "boolean" }, @@ -11513,6 +11524,9 @@ ], "title": "Chatgptv2::LoginAccountParamsType", "type": "string" + }, + "useHostedLoginSuccessPage": { + "type": "boolean" } }, "required": [ @@ -11667,6 +11681,13 @@ ], "title": "LoginAccountResponse" }, + "LoginAppBrand": { + "enum": [ + "codex", + "chatgpt" + ], + "type": "string" + }, "LogoutAccountResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LogoutAccountResponse", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 11540d40c7..29dfe78034 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7908,6 +7908,17 @@ }, { "properties": { + "appBrand": { + "anyOf": [ + { + "$ref": "#/definitions/LoginAppBrand" + }, + { + "type": "null" + } + ], + "default": null + }, "codexStreamlinedLogin": { "type": "boolean" }, @@ -7917,6 +7928,9 @@ ], "title": "Chatgptv2::LoginAccountParamsType", "type": "string" + }, + "useHostedLoginSuccessPage": { + "type": "boolean" } }, "required": [ @@ -8071,6 +8085,13 @@ ], "title": "LoginAccountResponse" }, + "LoginAppBrand": { + "enum": [ + "codex", + "chatgpt" + ], + "type": "string" + }, "LogoutAccountResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LogoutAccountResponse", diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index ab7b852c91..e6c82237d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -1,5 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "LoginAppBrand": { + "enum": [ + "codex", + "chatgpt" + ], + "type": "string" + } + }, "oneOf": [ { "properties": { @@ -23,6 +32,17 @@ }, { "properties": { + "appBrand": { + "anyOf": [ + { + "$ref": "#/definitions/LoginAppBrand" + }, + { + "type": "null" + } + ], + "default": null + }, "codexStreamlinedLogin": { "type": "boolean" }, @@ -32,6 +52,9 @@ ], "title": "Chatgptv2::LoginAccountParamsType", "type": "string" + }, + "useHostedLoginSuccessPage": { + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index e6f1e2ed43..1606f167fa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -1,8 +1,9 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LoginAppBrand } from "./LoginAppBrand"; -export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", +export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, useHostedLoginSuccessPage?: boolean, appBrand?: LoginAppBrand | null, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", /** * Access token (JWT) supplied by the client. * This token is used for backend API requests and email extraction. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts new file mode 100644 index 0000000000..c06d12ffff --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LoginAppBrand = "codex" | "chatgpt"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 00d39b31e3..070d21eab8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -194,6 +194,7 @@ export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams"; export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse"; export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; +export type { LoginAppBrand } from "./LoginAppBrand"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; export type { ManagedHooksRequirements } from "./ManagedHooksRequirements"; export type { MarketplaceAddParams } from "./MarketplaceAddParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c4b94d8b3f..e693e8d451 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2721,7 +2721,9 @@ mod tests { let request = ClientRequest::LoginAccount { request_id: RequestId::Integer(3), params: v2::LoginAccountParams::Chatgpt { + app_brand: None, codex_streamlined_login: false, + use_hosted_login_success_page: false, }, }; assert_eq!( @@ -2729,7 +2731,8 @@ mod tests { "method": "account/login/start", "id": 3, "params": { - "type": "chatgpt" + "type": "chatgpt", + "appBrand": null } }), serde_json::to_value(&request)?, @@ -2742,7 +2745,9 @@ mod tests { let request = ClientRequest::LoginAccount { request_id: RequestId::Integer(3), params: v2::LoginAccountParams::Chatgpt { + app_brand: None, codex_streamlined_login: true, + use_hosted_login_success_page: false, }, }; assert_eq!( @@ -2751,6 +2756,7 @@ mod tests { "id": 3, "params": { "type": "chatgpt", + "appBrand": null, "codexStreamlinedLogin": true } }), @@ -2759,6 +2765,32 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_with_hosted_success_page() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(3), + params: v2::LoginAccountParams::Chatgpt { + app_brand: Some(v2::LoginAppBrand::Chatgpt), + codex_streamlined_login: true, + use_hosted_login_success_page: true, + }, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 3, + "params": { + "type": "chatgpt", + "appBrand": "chatgpt", + "codexStreamlinedLogin": true, + "useHostedLoginSuccessPage": true + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_account_login_chatgpt_device_code() -> Result<()> { let request = ClientRequest::LoginAccount { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 788c8f7aaf..44551e400d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -78,6 +78,11 @@ pub enum LoginAccountParams { Chatgpt { #[serde(default, skip_serializing_if = "std::ops::Not::not")] codex_streamlined_login: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + use_hosted_login_success_page: bool, + #[serde(default)] + #[ts(optional = nullable)] + app_brand: Option, }, #[serde(rename = "chatgptDeviceCode")] #[ts(rename = "chatgptDeviceCode")] @@ -102,6 +107,16 @@ pub enum LoginAccountParams { }, } +#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum LoginAppBrand { + #[default] + Codex, + Chatgpt, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a70945c468..0e32365d12 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1722,7 +1722,9 @@ impl CodexClient { let request = ClientRequest::LoginAccount { request_id: request_id.clone(), params: codex_app_server_protocol::LoginAccountParams::Chatgpt { + app_brand: None, codex_streamlined_login: false, + use_hosted_login_success_page: false, }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d52f1bd168..6ee6baf2dc 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1995,6 +1995,11 @@ Field notes: { "id": 3, "result": { "type": "chatgpt", "loginId": "", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A%2Fauth%2Fcallback" } } ``` 2. Open `authUrl` in a browser; the app-server hosts the local callback. + By default, a successful callback redirects to the local success page. Clients may set + `useHostedLoginSuccessPage: true` to redirect successful callbacks that do not require + organization setup to the hosted Codex success page instead. When hosted login success is + enabled, clients may set `appBrand` to `"codex"` or `"chatgpt"` to select the matching hosted + page artwork; omitted or `null` values default to `"codex"`. 3. Wait for notifications: ```json { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f03351468f..896a166d84 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -94,6 +94,7 @@ use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::LoginAppBrand; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; @@ -379,7 +380,10 @@ use codex_feedback::FeedbackUploadOptions; use codex_git_utils::git_diff_to_remote; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManager; +use codex_login::CODEX_OPEN_APP_URL; use codex_login::CodexAuth; +use codex_login::LoginSuccessPage; +use codex_login::LoginSuccessPageBrand; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::auth::login_with_chatgpt_auth_tokens; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 9592563bec..2b2c018520 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -9,9 +9,11 @@ const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 1000); -// The override is intentionally available only in debug builds, matching the login path below. +// Login overrides are intentionally available only in debug builds. #[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; +#[cfg(debug_assertions)] +const LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL"; enum ActiveLogin { Browser { @@ -253,9 +255,25 @@ impl AccountRequestProcessor { .await; } LoginAccountParams::Chatgpt { + app_brand, codex_streamlined_login, + use_hosted_login_success_page, } => { - self.login_chatgpt_v2(request_id, codex_streamlined_login) + let login_success_page = if use_hosted_login_success_page { + let app_brand = match app_brand.unwrap_or_default() { + LoginAppBrand::Codex => LoginSuccessPageBrand::Codex, + LoginAppBrand::Chatgpt => LoginSuccessPageBrand::Chatgpt, + }; + LoginSuccessPage::Hosted { + url: CODEX_OPEN_APP_URL.parse().map_err(|err| { + internal_error(format!("invalid Codex open app URL: {err}")) + })?, + app_brand, + } + } else { + LoginSuccessPage::default() + }; + self.login_chatgpt_v2(request_id, codex_streamlined_login, login_success_page) .await; } LoginAccountParams::ChatgptDeviceCode => { @@ -341,6 +359,7 @@ impl AccountRequestProcessor { async fn login_chatgpt_common( &self, codex_streamlined_login: bool, + login_success_page: LoginSuccessPage, ) -> std::result::Result { let config = self.config.as_ref(); @@ -357,6 +376,7 @@ impl AccountRequestProcessor { let opts = LoginServerOptions { open_browser: false, codex_streamlined_login, + login_success_page, ..LoginServerOptions::new( config.codex_home.to_path_buf(), oauth_client_id(), @@ -374,6 +394,14 @@ impl AccountRequestProcessor { { opts.issuer = issuer; } + if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page + && let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR) + && !open_app_url.trim().is_empty() + { + *url = open_app_url + .parse() + .map_err(|err| internal_error(format!("invalid Codex open app URL: {err}")))?; + } opts }; @@ -393,16 +421,22 @@ impl AccountRequestProcessor { &self, request_id: ConnectionRequestId, codex_streamlined_login: bool, + login_success_page: LoginSuccessPage, ) { - let result = self.login_chatgpt_response(codex_streamlined_login).await; + let result = self + .login_chatgpt_response(codex_streamlined_login, login_success_page) + .await; self.outgoing.send_result(request_id, result).await; } async fn login_chatgpt_response( &self, codex_streamlined_login: bool, + login_success_page: LoginSuccessPage, ) -> Result { - let opts = self.login_chatgpt_common(codex_streamlined_login).await?; + let opts = self + .login_chatgpt_common(codex_streamlined_login, login_success_page) + .await?; let server = run_login_server(opts) .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; let login_id = Uuid::new_v4(); @@ -474,7 +508,10 @@ impl AccountRequestProcessor { &self, ) -> Result { let opts = self - .login_chatgpt_common(/*codex_streamlined_login*/ false) + .login_chatgpt_common( + /*codex_streamlined_login*/ false, + LoginSuccessPage::default(), + ) .await?; let device_code = request_device_code(&opts) .await 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 0fc0594baa..8605bc1a38 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -553,8 +553,8 @@ impl TestAppServer { chatgpt_account_id, chatgpt_plan_type, }; - let params = Some(serde_json::to_value(params)?); - self.send_request("account/login/start", params).await + self.send_login_account_request(serde_json::to_value(params)?) + .await } /// Send a `feedback/upload` JSON-RPC request. @@ -1420,6 +1420,14 @@ impl TestAppServer { self.send_request("account/logout", /*params*/ None).await } + /// Send an `account/login/start` JSON-RPC request. + pub async fn send_login_account_request( + &mut self, + params: serde_json::Value, + ) -> anyhow::Result { + self.send_request("account/login/start", Some(params)).await + } + /// Send an `account/login/start` JSON-RPC request for API key login. pub async fn send_login_account_api_key_request( &mut self, @@ -1429,7 +1437,7 @@ impl TestAppServer { "type": "apiKey", "apiKey": api_key, }); - self.send_request("account/login/start", Some(params)).await + self.send_login_account_request(params).await } /// Send an `account/login/start` JSON-RPC request for ChatGPT login. @@ -1437,7 +1445,7 @@ impl TestAppServer { let params = serde_json::json!({ "type": "chatgpt" }); - self.send_request("account/login/start", Some(params)).await + self.send_login_account_request(params).await } /// Send an `account/login/start` JSON-RPC request for ChatGPT device code login. @@ -1445,7 +1453,7 @@ impl TestAppServer { let params = serde_json::json!({ "type": "chatgptDeviceCode" }); - self.send_request("account/login/start", Some(params)).await + self.send_login_account_request(params).await } /// Send an `account/login/cancel` JSON-RPC request. diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 3cd98e2e52..f0a7b1d087 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -57,6 +57,7 @@ use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; +const LOGIN_OPEN_APP_URL_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL"; const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; @@ -181,7 +182,7 @@ async fn mock_device_code_token_failure(server: &MockServer, status: u16) { .await; } -async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) { +async fn mock_oauth_token(server: &MockServer, id_token: &str) { Mock::given(method("POST")) .and(path("/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -1097,7 +1098,7 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> .plan_type("pro") .chatgpt_account_id(WORKSPACE_ID_DEVICE), )?; - mock_device_code_oauth_token(&mock_server, &id_token).await; + mock_oauth_token(&mock_server, &id_token).await; let issuer = mock_server.uri(); let mut mcp = TestAppServer::new_with_env( @@ -1436,6 +1437,77 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> { Ok(()) } +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_redirects_to_hosted_success_page() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + let mock_server = MockServer::start().await; + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("hosted@example.com") + .plan_type("pro") + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), + )?; + mock_oauth_token(&mock_server, &id_token).await; + let issuer = mock_server.uri(); + + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ( + LOGIN_OPEN_APP_URL_ENV_VAR, + Some("http://localhost:3000/codex/open-app"), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_login_account_request(json!({ + "type": "chatgpt", + "appBrand": "chatgpt", + "useHostedLoginSuccessPage": true, + })) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { auth_url, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + let auth_url = Url::parse(&auth_url)?; + let callback_url = auth_url + .query_pairs() + .find_map(|(key, value)| (key == "redirect_uri").then(|| value.into_owned())) + .ok_or_else(|| anyhow::anyhow!("missing redirect_uri"))?; + let state = auth_url + .query_pairs() + .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) + .ok_or_else(|| anyhow::anyhow!("missing state"))?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let response = client + .get(format!("{callback_url}?code=test-code&state={state}")) + .send() + .await?; + + assert_eq!(response.status(), 302); + assert_eq!( + response.headers()["location"].to_str()?, + "http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt" + ); + Ok(()) +} + #[tokio::test] // Serialize tests that launch the login server since it binds to a fixed port. #[serial(login_port)] diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 8a08ddeadb..c5a3c1e28b 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -6,6 +6,7 @@ mod device_code_auth; mod outbound_proxy; mod pkce; mod server; +mod success_page; pub use codex_config::types::AuthCredentialsStoreMode; pub use codex_http_client::BuildCustomCaTransportError as BuildLoginHttpClientError; @@ -17,6 +18,9 @@ pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; pub use server::run_login_server; +pub use success_page::CODEX_OPEN_APP_URL; +pub use success_page::LoginSuccessPage; +pub use success_page::LoginSuccessPageBrand; pub use auth::AgentIdentityAuthPolicy; pub use auth::AuthConfig; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 607e47fefe..01671bf0b1 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -32,6 +32,10 @@ use crate::default_client::originator; use crate::outbound_proxy::AuthRouteConfig; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; +use crate::success_page::LoginSuccessPage; +use crate::success_page::LoginSuccessRedirect; +use crate::success_page::compose_success_url; +use crate::success_page::jwt_auth_claims; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; @@ -50,7 +54,7 @@ use tracing::error; use tracing::info; use tracing::warn; -const DEFAULT_ISSUER: &str = "https://auth.openai.com"; +pub(super) const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; // Keep in sync with the Codex CLI Hydra redirect URI allow-list. const FALLBACK_PORT: u16 = 1457; @@ -70,6 +74,7 @@ pub struct ServerOptions { pub force_state: Option, pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, + pub login_success_page: LoginSuccessPage, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, pub auth_route_config: Option, @@ -94,6 +99,7 @@ impl ServerOptions { force_state: None, forced_chatgpt_workspace_id, codex_streamlined_login: false, + login_success_page: LoginSuccessPage::default(), cli_auth_credentials_store_mode, auth_keyring_backend_kind, auth_route_config, @@ -207,28 +213,62 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let url_raw = req.url().to_string(); let response = - process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await; + process_request( + &url_raw, + &opts, + &redirect_uri, + &pkce, + actual_port, + &state, + ) + .await; let exit_result = match response { HandledRequest::Response(response) => { let _ = tokio::task::spawn_blocking(move || req.respond(response)).await; None } + HandledRequest::RedirectWithHeader(header) => { + let redirect = Response::empty(302).with_header(header); + let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; + None + } HandledRequest::ResponseAndExit { headers, body, result, } => { let _ = tokio::task::spawn_blocking(move || { - send_response_with_disconnect(req, headers, body) + send_response_with_disconnect( + req, + StatusCode(200), + headers, + body, + ) }) .await; Some(result) } - HandledRequest::RedirectWithHeader(header) => { - let redirect = Response::empty(302).with_header(header); - let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; - None + HandledRequest::RedirectAndExit(header) => { + match tokio::task::spawn_blocking(move || { + send_response_with_disconnect( + req, + StatusCode(302), + vec![header], + Vec::new(), + ) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!("failed to send hosted login redirect: {err}"); + } + Err(err) => { + warn!("hosted login redirect task failed: {err}"); + } + } + Some(Ok(())) } }; @@ -258,6 +298,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { enum HandledRequest { Response(Response>>), RedirectWithHeader(Header), + RedirectAndExit(Header), ResponseAndExit { headers: Vec
, body: Vec, @@ -392,15 +433,26 @@ async fn process_request( ); } - let success_url = compose_success_url( + let redirect = compose_success_url( actual_port, &opts.issuer, &tokens.id_token, &tokens.access_token, opts.codex_streamlined_login, + &opts.login_success_page, ); - match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) { - Ok(header) => HandledRequest::RedirectWithHeader(header), + let url = match &redirect { + LoginSuccessRedirect::Local(url) | LoginSuccessRedirect::Hosted(url) => url, + }; + match tiny_http::Header::from_bytes(&b"Location"[..], url.as_bytes()) { + Ok(header) => match redirect { + LoginSuccessRedirect::Local(_) => { + HandledRequest::RedirectWithHeader(header) + } + LoginSuccessRedirect::Hosted(_) => { + HandledRequest::RedirectAndExit(header) + } + }, Err(_) => login_error_response( "Sign-in completed but redirecting back to Codex failed.", io::ErrorKind::Other, @@ -465,10 +517,10 @@ async fn process_request( /// server-side connection persistence, but it does not. fn send_response_with_disconnect( req: Request, + status: StatusCode, mut headers: Vec
, body: Vec, ) -> io::Result<()> { - let status = StatusCode(200); let mut writer = req.into_writer(); let reason = status.default_reason_phrase(); write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?; @@ -850,94 +902,6 @@ pub(crate) async fn persist_tokens_async( .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? } -fn compose_success_url( - port: u16, - issuer: &str, - id_token: &str, - access_token: &str, - codex_streamlined_login: bool, -) -> String { - let token_claims = jwt_auth_claims(id_token); - let access_claims = jwt_auth_claims(access_token); - - let org_id = token_claims - .get("organization_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let project_id = token_claims - .get("project_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let completed_onboarding = token_claims - .get("completed_platform_onboarding") - .and_then(JsonValue::as_bool) - .unwrap_or(false); - let is_org_owner = token_claims - .get("is_org_owner") - .and_then(JsonValue::as_bool) - .unwrap_or(false); - let needs_setup = (!completed_onboarding) && is_org_owner; - let plan_type = access_claims - .get("chatgpt_plan_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let platform_url = if issuer == DEFAULT_ISSUER { - "https://platform.openai.com" - } else { - "https://platform.api.openai.org" - }; - - let mut params = vec![ - ("id_token", id_token.to_string()), - ("needs_setup", needs_setup.to_string()), - ("org_id", org_id.to_string()), - ("project_id", project_id.to_string()), - ("plan_type", plan_type.to_string()), - ("platform_url", platform_url.to_string()), - ]; - if codex_streamlined_login { - params.push(("codex_streamlined_login", "true".to_string())); - } - let qs = params - .drain(..) - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v))) - .collect::>() - .join("&"); - format!("http://localhost:{port}/success?{qs}") -} - -fn jwt_auth_claims(jwt: &str) -> serde_json::Map { - let mut parts = jwt.split('.'); - let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => { - eprintln!("Invalid JWT format while extracting claims"); - return serde_json::Map::new(); - } - }; - match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) { - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(mut v) => { - if let Some(obj) = v - .get_mut("https://api.openai.com/auth") - .and_then(|x| x.as_object_mut()) - { - return obj.clone(); - } - eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object"); - } - Err(e) => { - eprintln!("Failed to parse JWT JSON payload: {e}"); - } - }, - Err(e) => { - eprintln!("Failed to base64url-decode JWT payload: {e}"); - } - } - serde_json::Map::new() -} - /// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( expected: Option<&[String]>, @@ -1184,9 +1148,7 @@ pub(crate) async fn obtain_api_key( mod tests { use pretty_assertions::assert_eq; - use super::DEFAULT_ISSUER; use super::TokenEndpointErrorDetail; - use super::compose_success_url; use super::html_escape; use super::is_missing_codex_entitlement_error; use super::parse_token_endpoint_error; @@ -1293,43 +1255,6 @@ mod tests { ); } - #[test] - fn compose_success_url_omits_streamlined_success_by_default() { - let url = url::Url::parse(&compose_success_url( - /*port*/ 1455, - DEFAULT_ISSUER, - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - /*codex_streamlined_login*/ false, - )) - .expect("success url should parse"); - - assert_eq!( - url.query_pairs() - .find(|(key, _)| key == "codex_streamlined_login"), - None - ); - } - - #[test] - fn compose_success_url_includes_streamlined_success_when_requested() { - let url = url::Url::parse(&compose_success_url( - /*port*/ 1455, - DEFAULT_ISSUER, - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - /*codex_streamlined_login*/ true, - )) - .expect("success url should parse"); - - assert_eq!( - url.query_pairs() - .find(|(key, _)| key == "codex_streamlined_login") - .map(|(_, value)| value.into_owned()), - Some("true".to_string()) - ); - } - #[test] fn render_login_error_page_escapes_dynamic_fields() { let body = String::from_utf8(render_login_error_page( diff --git a/codex-rs/login/src/success_page.rs b/codex-rs/login/src/success_page.rs new file mode 100644 index 0000000000..4c7983e63c --- /dev/null +++ b/codex-rs/login/src/success_page.rs @@ -0,0 +1,143 @@ +use base64::Engine; +use serde_json::Value as JsonValue; +use url::Url; + +use crate::server::DEFAULT_ISSUER; + +pub const CODEX_OPEN_APP_URL: &str = "https://chatgpt.com/codex/open-app"; + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub enum LoginSuccessPage { + #[default] + Local, + Hosted { + url: Url, + app_brand: LoginSuccessPageBrand, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum LoginSuccessPageBrand { + Codex, + Chatgpt, +} + +impl LoginSuccessPageBrand { + fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Chatgpt => "chatgpt", + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum LoginSuccessRedirect { + Local(String), + Hosted(String), +} + +pub(crate) fn compose_success_url( + port: u16, + issuer: &str, + id_token: &str, + access_token: &str, + codex_streamlined_login: bool, + login_success_page: &LoginSuccessPage, +) -> LoginSuccessRedirect { + let token_claims = jwt_auth_claims(id_token); + + let org_id = token_claims + .get("organization_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let project_id = token_claims + .get("project_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let completed_onboarding = token_claims + .get("completed_platform_onboarding") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let is_org_owner = token_claims + .get("is_org_owner") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let needs_setup = !completed_onboarding && is_org_owner; + if !needs_setup && let LoginSuccessPage::Hosted { url, app_brand } = login_success_page { + let mut success_url = url.clone(); + success_url.set_query(None); + success_url + .query_pairs_mut() + .append_pair("source", "login") + .append_pair("app_brand", app_brand.as_str()); + return LoginSuccessRedirect::Hosted(success_url.into()); + } + + let access_claims = jwt_auth_claims(access_token); + let plan_type = access_claims + .get("chatgpt_plan_type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let platform_url = if issuer == DEFAULT_ISSUER { + "https://platform.openai.com" + } else { + "https://platform.api.openai.org" + }; + let mut params = vec![ + ("id_token", id_token.to_string()), + ("needs_setup", needs_setup.to_string()), + ("org_id", org_id.to_string()), + ("project_id", project_id.to_string()), + ("plan_type", plan_type.to_string()), + ("platform_url", platform_url.to_string()), + ]; + if codex_streamlined_login { + params.push(("codex_streamlined_login", "true".to_string())); + } + let query = params + .into_iter() + .map(|(key, value)| format!("{key}={}", urlencoding::encode(&value))) + .collect::>() + .join("&"); + LoginSuccessRedirect::Local(format!("http://localhost:{port}/success?{query}")) +} + +pub(crate) fn jwt_auth_claims(jwt: &str) -> serde_json::Map { + let mut parts = jwt.split('.'); + let (_header, payload, _signature) = match (parts.next(), parts.next(), parts.next()) { + (Some(header), Some(payload), Some(signature)) + if !header.is_empty() && !payload.is_empty() && !signature.is_empty() => + { + (header, payload, signature) + } + _ => { + eprintln!("Invalid JWT format while extracting claims"); + return serde_json::Map::new(); + } + }; + match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(mut value) => { + if let Some(claims) = value + .get_mut("https://api.openai.com/auth") + .and_then(JsonValue::as_object_mut) + { + return claims.clone(); + } + eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object"); + } + Err(error) => { + eprintln!("Failed to parse JWT JSON payload: {error}"); + } + }, + Err(error) => { + eprintln!("Failed to base64url-decode JWT payload: {error}"); + } + } + serde_json::Map::new() +} + +#[cfg(test)] +#[path = "success_page_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/success_page_tests.rs b/codex-rs/login/src/success_page_tests.rs new file mode 100644 index 0000000000..5ccfc9e563 --- /dev/null +++ b/codex-rs/login/src/success_page_tests.rs @@ -0,0 +1,120 @@ +use base64::Engine; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +#[test] +fn compose_success_url_uses_local_page_by_default() { + let LoginSuccessRedirect::Local(url) = compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ false, + &LoginSuccessPage::default(), + ) else { + panic!("expected local success redirect"); + }; + let url = Url::parse(&url).expect("success URL should parse"); + + assert_eq!(url.host_str(), Some("localhost")); + assert_eq!(url.path(), "/success"); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "codex_streamlined_login"), + None + ); +} + +#[test] +fn compose_success_url_uses_streamlined_local_page_when_requested() { + let LoginSuccessRedirect::Local(url) = compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ true, + &LoginSuccessPage::default(), + ) else { + panic!("expected local success redirect"); + }; + let url = Url::parse(&url).expect("success URL should parse"); + + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "codex_streamlined_login") + .map(|(_, value)| value.into_owned()), + Some("true".to_string()) + ); +} + +#[test] +fn compose_success_url_uses_hosted_page_when_requested() { + assert_eq!( + compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ false, + &LoginSuccessPage::Hosted { + url: Url::parse(CODEX_OPEN_APP_URL).expect("open app URL should parse"), + app_brand: LoginSuccessPageBrand::Chatgpt, + }, + ), + LoginSuccessRedirect::Hosted( + "https://chatgpt.com/codex/open-app?source=login&app_brand=chatgpt".to_string() + ) + ); +} + +#[test] +fn compose_success_url_keeps_setup_on_local_page() { + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let payload = encode( + serde_json::to_string(&json!({ + "https://api.openai.com/auth": { + "completed_platform_onboarding": false, + "is_org_owner": true, + "organization_id": "org_123", + "project_id": "proj_123", + } + })) + .expect("payload should serialize") + .as_bytes(), + ); + let access_payload = encode( + serde_json::to_string(&json!({ + "https://api.openai.com/auth": { + "chatgpt_plan_type": "team", + } + })) + .expect("payload should serialize") + .as_bytes(), + ); + let id_token = format!("e30.{payload}.sig"); + let LoginSuccessRedirect::Local(url) = compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + &id_token, + &format!("e30.{access_payload}.sig"), + /*codex_streamlined_login*/ true, + &LoginSuccessPage::Hosted { + url: Url::parse(CODEX_OPEN_APP_URL).expect("open app URL should parse"), + app_brand: LoginSuccessPageBrand::Codex, + }, + ) else { + panic!("expected local success redirect"); + }; + let url = Url::parse(&url).expect("success URL should parse"); + + assert_eq!(url.host_str(), Some("localhost")); + assert_eq!(url.path(), "/success"); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "needs_setup") + .map(|(_, value)| value.into_owned()), + Some("true".to_string()) + ); +} diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 6f7b4e4f83..dbcd396ef1 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -10,6 +10,8 @@ use anyhow::Result; use base64::Engine; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; +use codex_login::LoginSuccessPage; +use codex_login::LoginSuccessPageBrand; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; @@ -131,6 +133,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]), codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; assert!( @@ -141,13 +144,20 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { ); let login_port = server.actual_port; - // Simulate browser callback, and follow redirect to /success + // Simulate browser callback and assert the local success redirect before following it. let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::limited(5)) + .redirect(reqwest::redirect::Policy::none()) .build()?; let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=test_state_123"); let resp = client.get(&url).send().await?; - assert!(resp.status().is_success()); + assert_eq!(resp.status(), 302); + let success_url = resp.headers()["location"].to_str()?; + let success_url = Url::parse(success_url)?; + assert_eq!(success_url.host_str(), Some("localhost")); + assert_eq!(success_url.path(), "/success"); + + let success_resp = client.get(success_url).send().await?; + assert!(success_resp.status().is_success()); // Wait for server shutdown server.block_until_done().await?; @@ -169,6 +179,52 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { Ok(()) } +#[tokio::test] +async fn hosted_login_redirects_to_configured_open_app_url() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + let tmp = tempdir()?; + let server = run_login_server(ServerOptions { + codex_home: tmp.path().to_path_buf(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some("streamlined_state".to_string()), + forced_chatgpt_workspace_id: None, + codex_streamlined_login: false, + login_success_page: LoginSuccessPage::Hosted { + url: Url::parse("http://localhost:3000/codex/open-app?source=old")?, + app_brand: LoginSuccessPageBrand::Chatgpt, + }, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + })?; + let login_port = server.actual_port; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let response = client + .get(format!( + "http://127.0.0.1:{login_port}/auth/callback?code=abc&state=streamlined_state" + )) + .send() + .await?; + + assert_eq!(response.status(), 302); + assert_eq!( + response.headers()["location"].to_str()?, + "http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt" + ); + tokio::time::timeout(Duration::from_secs(1), server.block_until_done()).await??; + + Ok(()) +} + #[tokio::test] async fn creates_missing_codex_home_dir() -> Result<()> { skip_if_no_network!(Ok(())); @@ -195,6 +251,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -240,6 +297,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< ]), codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let auth_url = Url::parse(&server.auth_url)?; @@ -280,6 +338,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; assert!( @@ -342,6 +401,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -412,6 +472,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -561,6 +622,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let first_server = run_login_server(first_opts)?; @@ -584,6 +646,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index f9392eb2e8..c5c4ef442d 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -874,7 +874,9 @@ impl AuthModeWidget { .request_typed::(ClientRequest::LoginAccount { request_id: onboarding_request_id(), params: LoginAccountParams::Chatgpt { + app_brand: None, codex_streamlined_login: false, + use_hosted_login_success_page: false, }, }) .await From 1f710c973b0a4b552a317b90b12bbd7baaad96f8 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Tue, 7 Jul 2026 12:57:19 -0700 Subject: [PATCH 04/53] chore: extract remote compaction request attempts (#31316) ## Why This PR is a behavior-preserving refactor only. It does not add a fallback, change which model is used for compaction, or otherwise change compaction behavior. The behavioral change is implemented in the stacked follow-up, #30319. Pre-sampling compaction deliberately uses the previous turn's context when the compaction compatibility hash changes or when switching to a model with a smaller context window. That preserves the model settings that produced the history being compacted, but the previous context is not always usable. For example, a resumed thread can still reference a model slug that has since been retired, causing compaction to fail before the currently selected model can sample. #30319 addresses that failure mode by retrying compaction with the current turn's selected model when the backend rejects the previous-model attempt. This PR performs only that preparatory refactor. ## What changed - Extracted one legacy `/responses/compact` request attempt into `compact_remote_request.rs`. - Extracted one Responses-based remote compaction request attempt into `compact_remote_v2_attempt.rs`. - Kept hooks, lifecycle events, analytics, window advancement, history processing and installation, and error behavior unchanged in the existing orchestration paths. - Preserved standalone Responses-based compaction's owned client-session lifetime through lifecycle completion. ## Testing - `just test -p codex-core -E 'test(remote_compact)'` (22 tests) --- codex-rs/core/src/compact_remote.rs | 92 +++---------- codex-rs/core/src/compact_remote_request.rs | 107 +++++++++++++++ codex-rs/core/src/compact_remote_v2.rs | 106 +++------------ .../core/src/compact_remote_v2_attempt.rs | 126 ++++++++++++++++++ 4 files changed, 266 insertions(+), 165 deletions(-) create mode 100644 codex-rs/core/src/compact_remote_request.rs create mode 100644 codex-rs/core/src/compact_remote_v2_attempt.rs diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 6b251562f7..8964b9aa42 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -1,8 +1,6 @@ use std::sync::Arc; use std::sync::OnceLock; -use crate::Prompt; -use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; @@ -15,17 +13,14 @@ use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; -use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; use crate::session::session::Session; use crate::session::step_context::StepContext; -use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; -use codex_protocol::auth::AuthMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -38,8 +33,11 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; -use tokio_util::sync::CancellationToken; -use tracing::info; + +#[path = "compact_remote_request.rs"] +mod request; +use request::RemoteCompactAttempt; +use request::run_remote_compact_attempt; const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = "Output exceeded the available model context and was truncated"; @@ -193,78 +191,18 @@ async fn run_remote_compact_task_inner_impl( let compaction_item = TurnItem::ContextCompaction(context_compaction_item); sess.emit_turn_item_started(turn_context, &compaction_item) .await; - let mut history = sess.clone_history().await; - let base_instructions = sess.get_base_instructions().await; - let (rewritten_outputs, estimated_deleted_tokens) = - trim_function_call_history_to_fit_context_window( - &mut history, - turn_context.as_ref(), - &base_instructions, - ); - if rewritten_outputs > 0 { - info!( - turn_id = %turn_context.sub_id, - rewritten_outputs, - "rewrote history outputs before remote compaction" - ); - } - if estimated_deleted_tokens > 0 { - let max_local_deleted_tokens = sess - .estimated_tokens_after_last_model_generated_item() - .await; - analytics_details.active_context_tokens_before = analytics_details - .active_context_tokens_before - .map(|active_context_tokens_before| { - active_context_tokens_before - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) - }); - } - // This is the history selected for remote compaction, after any output rewriting required to - // fit the compact endpoint. The checkpoint below records it separately from the next sampling - // request, whose prompt will repeat current developer/context prefix items. - let trace_input_history = history.raw_items().to_vec(); - let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); - let tool_router = built_tools( - sess.as_ref(), - step_context.as_ref(), - &CancellationToken::new(), + let RemoteCompactAttempt { + new_history, + trace_input_history, + } = run_remote_compact_attempt( + sess, + step_context, + turn_state, + &compaction_trace, + compaction_metadata, + analytics_details, ) .await?; - let prompt = Prompt { - input: prompt_input, - tools: tool_router.model_visible_specs(), - parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, - base_instructions, - output_schema: None, - output_schema_strict: true, - }; - let window_id = sess.current_window_id().await; - let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( - sess.installation_id.clone(), - window_id, - CodexResponsesRequestKind::Compaction(compaction_metadata), - ); - let new_history = sess - .services - .model_client - .compact_conversation_history( - &prompt, - &turn_context.model_info, - turn_state, - CompactConversationRequestSettings { - effort: turn_context.reasoning_effort.clone(), - summary: turn_context.reasoning_summary, - service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { - None - } else { - turn_context.config.service_tier.clone() - }, - }, - &turn_context.session_telemetry, - &compaction_trace, - &responses_metadata, - ) - .await?; let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let (new_history, world_state_baseline) = process_compacted_history( sess.as_ref(), diff --git a/codex-rs/core/src/compact_remote_request.rs b/codex-rs/core/src/compact_remote_request.rs new file mode 100644 index 0000000000..bbf2f04446 --- /dev/null +++ b/codex-rs/core/src/compact_remote_request.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; +use std::sync::OnceLock; + +use super::trim_function_call_history_to_fit_context_window; +use crate::Prompt; +use crate::client::CompactConversationRequestSettings; +use crate::compact::CompactionAnalyticsDetails; +use crate::responses_metadata::CodexResponsesRequestKind; +use crate::responses_metadata::CompactionTurnMetadata; +use crate::session::session::Session; +use crate::session::step_context::StepContext; +use crate::session::turn::built_tools; +use codex_protocol::auth::AuthMode; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::ResponseItem; +use codex_rollout_trace::CompactionTraceContext; +use tokio_util::sync::CancellationToken; +use tracing::info; + +pub(super) struct RemoteCompactAttempt { + pub(super) new_history: Vec, + pub(super) trace_input_history: Vec, +} + +pub(super) async fn run_remote_compact_attempt( + sess: &Arc, + step_context: &Arc, + turn_state: Option>>, + compaction_trace: &CompactionTraceContext, + compaction_metadata: CompactionTurnMetadata, + analytics_details: &mut CompactionAnalyticsDetails, +) -> CodexResult { + let turn_context = &step_context.turn; + let mut history = sess.clone_history().await; + let base_instructions = sess.get_base_instructions().await; + let (rewritten_outputs, estimated_deleted_tokens) = + trim_function_call_history_to_fit_context_window( + &mut history, + turn_context.as_ref(), + &base_instructions, + ); + if rewritten_outputs > 0 { + info!( + turn_id = %turn_context.sub_id, + rewritten_outputs, + "rewrote history outputs before remote compaction" + ); + } + if estimated_deleted_tokens > 0 { + let max_local_deleted_tokens = sess + .estimated_tokens_after_last_model_generated_item() + .await; + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); + } + let trace_input_history = history.raw_items().to_vec(); + let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); + let tool_router = built_tools( + sess.as_ref(), + step_context.as_ref(), + &CancellationToken::new(), + ) + .await?; + let prompt = Prompt { + input: prompt_input, + tools: tool_router.model_visible_specs(), + parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + base_instructions, + output_schema: None, + output_schema_strict: true, + }; + let window_id = sess.current_window_id().await; + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + sess.installation_id.clone(), + window_id, + CodexResponsesRequestKind::Compaction(compaction_metadata), + ); + let new_history = sess + .services + .model_client + .compact_conversation_history( + &prompt, + &turn_context.model_info, + turn_state, + CompactConversationRequestSettings { + effort: turn_context.reasoning_effort.clone(), + summary: turn_context.reasoning_summary, + service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { + None + } else { + turn_context.config.service_tier.clone() + }, + }, + &turn_context.session_telemetry, + compaction_trace, + &responses_metadata, + ) + .await?; + Ok(RemoteCompactAttempt { + new_history, + trace_input_history, + }) +} diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 6e110045bb..8105f63b7a 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -10,19 +10,16 @@ use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact_remote::process_compacted_history; use crate::compact_remote::should_keep_compacted_history_item; -use crate::compact_remote::trim_function_call_history_to_fit_context_window; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; use crate::responses_metadata::CodexResponsesMetadata; -use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_retry::ResponsesStreamRequest; use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::session::Session; use crate::session::step_context::StepContext; -use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; @@ -44,8 +41,11 @@ use codex_rollout_trace::InferenceTraceContext; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::truncate_text; use futures::StreamExt; -use tokio_util::sync::CancellationToken; -use tracing::info; + +#[path = "compact_remote_v2_attempt.rs"] +mod attempt; +use attempt::RemoteCompactV2Attempt; +use attempt::run_remote_compact_v2_attempt; // Mirror the current /responses/compact retained-message default while the // server-side path remains the reference implementation. @@ -203,91 +203,21 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_started(turn_context, &compaction_item) .await; - let mut history = sess.clone_history().await; - let base_instructions = sess.get_base_instructions().await; - let (rewritten_outputs, estimated_deleted_tokens) = - trim_function_call_history_to_fit_context_window( - &mut history, - turn_context.as_ref(), - &base_instructions, - ); - if rewritten_outputs > 0 { - info!( - turn_id = %turn_context.sub_id, - rewritten_outputs, - "rewrote history outputs before remote compaction v2" - ); - } - if estimated_deleted_tokens > 0 { - let max_local_deleted_tokens = sess - .estimated_tokens_after_last_model_generated_item() - .await; - analytics_details.active_context_tokens_before = analytics_details - .active_context_tokens_before - .map(|active_context_tokens_before| { - active_context_tokens_before - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) - }); - } - - let trace_input_history = history.raw_items().to_vec(); - let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); - let tool_router = built_tools( - sess.as_ref(), - step_context.as_ref(), - &CancellationToken::new(), - ) - .await?; - let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger {}); - let prompt = Prompt { - input, - tools: tool_router.model_visible_specs(), - parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, - base_instructions, - output_schema: None, - output_schema_strict: true, - }; - - let window_id = sess.current_window_id().await; - let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( - sess.installation_id.clone(), - window_id, - CodexResponsesRequestKind::Compaction(compaction_metadata), - ); - let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ - "model": turn_context.model_info.slug.as_str(), - "instructions": prompt.base_instructions.text.as_str(), - "input": &prompt.input, - "parallel_tool_calls": prompt.parallel_tool_calls, - })); - - let mut owned_client_session; - let client_session = match client_session { - Some(client_session) => client_session, - None => { - owned_client_session = sess.services.model_client.new_session(); - &mut owned_client_session - } - }; - let compaction_output_result = run_remote_compaction_request_v2( - sess, - turn_context, - client_session, - &prompt, - &responses_metadata, - ) - .await; - - trace_attempt.record_result( - compaction_output_result - .as_ref() - .map(|output| std::slice::from_ref(&output.compaction_output)), - ); - let RemoteCompactionV2Output { + let RemoteCompactV2Attempt { + trace_input_history, + prompt_input, compaction_output, token_usage, - } = compaction_output_result?; + owned_client_session: _owned_client_session, + } = run_remote_compact_v2_attempt( + sess, + step_context, + client_session, + &compaction_trace, + compaction_metadata, + analytics_details, + ) + .await?; if let Some(token_usage) = token_usage { sess.record_rollout_budget_usage(&token_usage)?; analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs new file mode 100644 index 0000000000..a145d00ae6 --- /dev/null +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use super::RemoteCompactionV2Output; +use super::run_remote_compaction_request_v2; +use crate::Prompt; +use crate::client::ModelClientSession; +use crate::compact::CompactionAnalyticsDetails; +use crate::compact_remote::trim_function_call_history_to_fit_context_window; +use crate::responses_metadata::CodexResponsesRequestKind; +use crate::responses_metadata::CompactionTurnMetadata; +use crate::session::session::Session; +use crate::session::step_context::StepContext; +use crate::session::turn::built_tools; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::TokenUsage; +use codex_rollout_trace::CompactionTraceContext; +use tokio_util::sync::CancellationToken; +use tracing::info; + +pub(super) struct RemoteCompactV2Attempt { + pub(super) trace_input_history: Vec, + pub(super) prompt_input: Vec, + pub(super) compaction_output: ResponseItem, + pub(super) token_usage: Option, + /// Keeps a session created for standalone compaction alive through lifecycle completion. + pub(super) owned_client_session: Option, +} + +pub(super) async fn run_remote_compact_v2_attempt( + sess: &Arc, + step_context: &Arc, + client_session: Option<&mut ModelClientSession>, + compaction_trace: &CompactionTraceContext, + compaction_metadata: CompactionTurnMetadata, + analytics_details: &mut CompactionAnalyticsDetails, +) -> CodexResult { + let turn_context = &step_context.turn; + let mut history = sess.clone_history().await; + let base_instructions = sess.get_base_instructions().await; + let (rewritten_outputs, estimated_deleted_tokens) = + trim_function_call_history_to_fit_context_window( + &mut history, + turn_context.as_ref(), + &base_instructions, + ); + if rewritten_outputs > 0 { + info!( + turn_id = %turn_context.sub_id, + rewritten_outputs, + "rewrote history outputs before remote compaction v2" + ); + } + if estimated_deleted_tokens > 0 { + let max_local_deleted_tokens = sess + .estimated_tokens_after_last_model_generated_item() + .await; + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); + } + + let trace_input_history = history.raw_items().to_vec(); + let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); + let tool_router = built_tools( + sess.as_ref(), + step_context.as_ref(), + &CancellationToken::new(), + ) + .await?; + let mut input = prompt_input.clone(); + input.push(ResponseItem::CompactionTrigger {}); + let prompt = Prompt { + input, + tools: tool_router.model_visible_specs(), + parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + base_instructions, + output_schema: None, + output_schema_strict: true, + }; + + let window_id = sess.current_window_id().await; + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + sess.installation_id.clone(), + window_id, + CodexResponsesRequestKind::Compaction(compaction_metadata), + ); + let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ + "model": turn_context.model_info.slug.as_str(), + "instructions": prompt.base_instructions.text.as_str(), + "input": &prompt.input, + "parallel_tool_calls": prompt.parallel_tool_calls, + })); + let mut owned_client_session = None; + let client_session = match client_session { + Some(client_session) => client_session, + None => owned_client_session.insert(sess.services.model_client.new_session()), + }; + let compaction_output_result = run_remote_compaction_request_v2( + sess, + turn_context.as_ref(), + client_session, + &prompt, + &responses_metadata, + ) + .await; + trace_attempt.record_result( + compaction_output_result + .as_ref() + .map(|output| std::slice::from_ref(&output.compaction_output)), + ); + let RemoteCompactionV2Output { + compaction_output, + token_usage, + } = compaction_output_result?; + Ok(RemoteCompactV2Attempt { + trace_input_history, + prompt_input, + compaction_output, + token_usage, + owned_client_session, + }) +} From 1bd9d841ca9ec8c99e4843f5be3d1d71cb5d8169 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 7 Jul 2026 12:57:42 -0700 Subject: [PATCH 05/53] feat(core): emit canonical sub-agent activity items (#31299) This PR depends on [#31296](https://github.com/openai/codex/pull/31296) for the canonical-to-legacy event mappings. ## Description This PR makes the MultiAgentV2 spawn, message/follow-up, and interrupt paths emit completed canonical `TurnItem::SubAgentActivity` items instead of `SubAgentActivityEvent` directly. App-server v2 now applies interrupted-agent thread-watch cleanup from the canonical completed item and ignores the mapped legacy activity event. ## Why Sub-agent activity is separate from the v1 collab tool begin/end lifecycle. Keeping it separate makes the v2 watcher side effect reviewable without mixing in the larger collab tool-call migration. ## What changed - Emit canonical sub-agent activity items from v2 spawn, message/follow-up, and interrupt paths. - Move missing-thread watcher cleanup onto canonical completed activity items. - Update focused app-server coverage to exercise canonical interrupted activity. --- .../app-server/src/bespoke_event_handling.rs | 91 ++++++++++++------- .../src/tools/handlers/multi_agents_v2.rs | 13 ++- .../multi_agents_v2/interrupt_agent.rs | 25 +++-- .../handlers/multi_agents_v2/message_tool.rs | 25 +++-- .../tools/handlers/multi_agents_v2/spawn.rs | 25 +++-- 5 files changed, 103 insertions(+), 76 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 863aeac79b..831715523b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -852,23 +852,10 @@ pub(crate) async fn apply_bespoke_event_handling( ); outgoing.send_server_notification(notification).await; } - EventMsg::SubAgentActivity(activity) => { - if activity.kind == SubAgentActivityKind::Interrupted - && thread_manager - .get_thread(activity.agent_thread_id) - .await - .is_err() - { - thread_watch_manager - .remove_thread(&activity.agent_thread_id.to_string()) - .await; - } - let notification = item_event_to_server_notification( - EventMsg::SubAgentActivity(activity), - &conversation_id.to_string(), - &event_turn_id, - ); - outgoing.send_server_notification(notification).await; + EventMsg::SubAgentActivity(_) => { + // Deprecated sub-agent activity events are still fanned out for raw-event and + // rollout compatibility consumers. App-server v2 receives the canonical + // SubAgentActivity item lifecycle instead. } EventMsg::CollabCloseEnd(end_event) => { if thread_manager @@ -1033,7 +1020,13 @@ pub(crate) async fn apply_bespoke_event_handling( } } EventMsg::ItemCompleted(event) => { - apply_canonical_item_completed_side_effects(&thread_state, &event.item).await; + apply_canonical_item_completed_side_effects( + &thread_manager, + &thread_watch_manager, + &thread_state, + &event.item, + ) + .await; let notification = item_event_to_server_notification( EventMsg::ItemCompleted(event), &conversation_id.to_string(), @@ -1334,16 +1327,43 @@ async fn emit_turn_completed_with_status( } async fn apply_canonical_item_completed_side_effects( + thread_manager: &Arc, + thread_watch_manager: &ThreadWatchManager, thread_state: &Arc>, item: &CoreTurnItem, ) { - if let CoreTurnItem::CommandExecution(item) = item { - thread_state - .lock() - .await - .turn_summary - .command_execution_started - .remove(&item.id); + match item { + CoreTurnItem::CommandExecution(item) => { + thread_state + .lock() + .await + .turn_summary + .command_execution_started + .remove(&item.id); + } + CoreTurnItem::SubAgentActivity(activity) + if activity.kind == SubAgentActivityKind::Interrupted => + { + remove_missing_thread_watch( + thread_manager, + thread_watch_manager, + activity.agent_thread_id, + ) + .await; + } + _ => {} + } +} + +async fn remove_missing_thread_watch( + thread_manager: &Arc, + thread_watch_manager: &ThreadWatchManager, + thread_id: ThreadId, +) { + if thread_manager.get_thread(thread_id).await.is_err() { + thread_watch_manager + .remove_thread(&thread_id.to_string()) + .await; } } @@ -2175,6 +2195,7 @@ mod tests { use codex_protocol::items::DynamicToolCallItem; use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus; use codex_protocol::items::HookPromptFragment; + use codex_protocol::items::SubAgentActivityItem; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; @@ -2192,12 +2213,12 @@ mod tests { use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; + use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::UserMessageEvent; @@ -3420,13 +3441,17 @@ mod tests { apply_bespoke_event_handling( Event { id: "turn-1".to_string(), - msg: EventMsg::SubAgentActivity(SubAgentActivityEvent { - event_id: "activity-1".to_string(), - occurred_at_ms: 42, - agent_thread_id: child_thread_id, - agent_path: AgentPath::try_from("/root/worker") - .expect("agent path should parse"), - kind: SubAgentActivityKind::Interrupted, + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: conversation_id, + turn_id: "turn-1".to_string(), + item: CoreTurnItem::SubAgentActivity(SubAgentActivityItem { + id: "activity-1".to_string(), + kind: SubAgentActivityKind::Interrupted, + agent_thread_id: child_thread_id, + agent_path: AgentPath::try_from("/root/worker") + .expect("agent path should parse"), + }), + completed_at_ms: 42, }), }, conversation_id, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 8e7f73a762..9473908a46 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -12,12 +12,13 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_protocol::AgentPath; +use codex_protocol::items::SubAgentActivityItem; +use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::protocol::InterAgentCommunication; -use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::SubAgentActivityKind; use codex_tools::ToolName; use serde::Deserialize; @@ -39,6 +40,16 @@ mod send_message; mod spawn; pub(crate) mod wait; +pub(crate) async fn emit_sub_agent_activity( + session: &crate::session::session::Session, + turn: &crate::session::turn_context::TurnContext, + item: SubAgentActivityItem, +) { + session + .emit_turn_item_completed(turn, TurnItem::SubAgentActivity(item)) + .await; +} + pub(super) fn communication_from_tool_message( author: AgentPath, recipient: AgentPath, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs index aeed055b4e..00eb149ca1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs @@ -1,6 +1,5 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; @@ -71,19 +70,17 @@ async fn handle_interrupt_agent( Err(err) => Err(collab_agent_error(agent_id, err)), }; result?; - session - .send_event( - &turn, - SubAgentActivityEvent { - event_id: call_id, - occurred_at_ms: now_unix_timestamp_ms(), - agent_thread_id: agent_id, - agent_path: receiver_agent_path, - kind: SubAgentActivityKind::Interrupted, - } - .into(), - ) - .await; + emit_sub_agent_activity( + &session, + &turn, + SubAgentActivityItem { + id: call_id, + agent_thread_id: agent_id, + agent_path: receiver_agent_path, + kind: SubAgentActivityKind::Interrupted, + }, + ) + .await; Ok(InterruptAgentResult { previous_status: status, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 9501b4355a..62defe65c4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -7,7 +7,6 @@ use super::*; use crate::agent_communication::AgentCommunicationContext; use crate::agent_communication::AgentCommunicationKind; use crate::tools::context::FunctionToolOutput; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] @@ -115,19 +114,17 @@ pub(crate) async fn handle_message_string_tool( .await .map_err(|err| collab_agent_error(receiver_thread_id, err)); result?; - session - .send_event( - &turn, - SubAgentActivityEvent { - event_id: call_id, - occurred_at_ms: now_unix_timestamp_ms(), - agent_thread_id: receiver_thread_id, - agent_path: receiver_agent_path, - kind: SubAgentActivityKind::Interacted, - } - .into(), - ) - .await; + emit_sub_agent_activity( + &session, + &turn, + SubAgentActivityItem { + id: call_id, + agent_thread_id: receiver_thread_id, + agent_path: receiver_agent_path, + kind: SubAgentActivityKind::Interacted, + }, + ) + .await; Ok(FunctionToolOutput::from_text(String::new(), Some(true))) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 8eeb6ddd1b..1c68849c5e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -9,7 +9,6 @@ use crate::agent_communication::AgentCommunicationKind; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::tools::handlers::multi_agents_v2::message_tool::message_content; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; use codex_tools::ToolSpec; @@ -140,19 +139,17 @@ async fn handle_spawn_agent( .as_ref() .and_then(|snapshot| snapshot.session_source.get_nickname()) .or(spawned_agent.metadata.agent_nickname); - session - .send_event( - &turn, - SubAgentActivityEvent { - event_id: call_id, - occurred_at_ms: now_unix_timestamp_ms(), - agent_thread_id: new_thread_id, - agent_path: new_agent_path.clone(), - kind: SubAgentActivityKind::Started, - } - .into(), - ) - .await; + emit_sub_agent_activity( + &session, + &turn, + SubAgentActivityItem { + id: call_id, + agent_thread_id: new_thread_id, + agent_path: new_agent_path.clone(), + kind: SubAgentActivityKind::Started, + }, + ) + .await; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", From 777f5da55c4715c2e5546435be011c518be105b7 Mon Sep 17 00:00:00 2001 From: hefuc-oai Date: Tue, 7 Jul 2026 13:00:44 -0700 Subject: [PATCH 06/53] [1/5] [codex] sync managed-layer bundle schema (#31285) ## Why The backend config-bundle contract now exposes managed configuration in `managed_layers`, split into `baseline` and `system_overlay`. The Rust transport models need to match that contract before any runtime behavior changes. ## What changed - add the generated `DeliveredManagedLayers` model - model `baseline` and `system_overlay` as required arrays - expose optional/null `managed_layers` on delivered config and requirements documents - retain `enterprise_managed` for transport compatibility This PR changes transport types only; cloud-config runtime behavior is unchanged. ## Stack 1 of 4. Next: #31286. ## Validation - `just test -p codex-backend-openapi-models -p codex-backend-client` - `just test -p codex-cloud-config` - revalidated against the current generated backend schema --- codex-rs/backend-client/src/lib.rs | 1 + codex-rs/backend-client/src/types.rs | 1 + codex-rs/cloud-config/src/service_tests.rs | 2 ++ .../src/models/delivered_config_toml.rs | 8 +++++ .../src/models/delivered_managed_layers.rs | 33 +++++++++++++++++++ .../src/models/delivered_requirements_toml.rs | 8 +++++ .../src/models/mod.rs | 3 ++ 7 files changed, 56 insertions(+) create mode 100644 codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 06f2cd442a..1e244e7d03 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -15,6 +15,7 @@ pub use types::ConfigBundleResponse; pub use types::ConsumeRateLimitResetCreditCode; pub use types::ConsumeRateLimitResetCreditResponse; pub use types::DeliveredConfigToml; +pub use types::DeliveredManagedLayers; pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; pub use types::PaginatedListTaskListItem; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 7fb4346a92..615fefd5fc 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,6 +1,7 @@ pub use codex_backend_openapi_models::models::ConfigBundleResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::DeliveredConfigToml; +pub use codex_backend_openapi_models::models::DeliveredManagedLayers; pub use codex_backend_openapi_models::models::DeliveredRequirementsToml; pub use codex_backend_openapi_models::models::DeliveredTomlFragment; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index dfd4fe50ce..85c3940bbb 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -1037,6 +1037,7 @@ fn bundle_response_conversion_preserves_fragment_order() { "model = \"low\"".to_string(), ), ])), + managed_layers: None, }))), requirements_toml: Some(Some(Box::new( codex_backend_client::DeliveredRequirementsToml { @@ -1045,6 +1046,7 @@ fn bundle_response_conversion_preserves_fragment_order() { "High requirements".to_string(), "allowed_approval_policies = [\"never\"]".to_string(), )])), + managed_layers: None, }, ))), }; diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs index 0183eced47..081fe4bd9c 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs @@ -21,12 +21,20 @@ pub struct DeliveredConfigToml { skip_serializing_if = "Option::is_none" )] pub enterprise_managed: Option>>, + #[serde( + rename = "managed_layers", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub managed_layers: Option>>, } impl DeliveredConfigToml { pub fn new() -> DeliveredConfigToml { DeliveredConfigToml { enterprise_managed: None, + managed_layers: None, } } } diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs new file mode 100644 index 0000000000..043c10b883 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs @@ -0,0 +1,33 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct DeliveredManagedLayers { + #[serde(rename = "baseline")] + pub baseline: Vec, + #[serde(rename = "system_overlay")] + pub system_overlay: Vec, +} + +impl DeliveredManagedLayers { + pub fn new( + baseline: Vec, + system_overlay: Vec, + ) -> DeliveredManagedLayers { + DeliveredManagedLayers { + baseline, + system_overlay, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs index 953087d734..bec357e187 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs @@ -21,12 +21,20 @@ pub struct DeliveredRequirementsToml { skip_serializing_if = "Option::is_none" )] pub enterprise_managed: Option>>, + #[serde( + rename = "managed_layers", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub managed_layers: Option>>, } impl DeliveredRequirementsToml { pub fn new() -> DeliveredRequirementsToml { DeliveredRequirementsToml { enterprise_managed: None, + managed_layers: None, } } } diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index c3f4e6beec..b154a044fd 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -13,6 +13,9 @@ pub use self::config_file_response::ConfigFileResponse; pub(crate) mod delivered_config_toml; pub use self::delivered_config_toml::DeliveredConfigToml; +pub(crate) mod delivered_managed_layers; +pub use self::delivered_managed_layers::DeliveredManagedLayers; + pub(crate) mod delivered_requirements_toml; pub use self::delivered_requirements_toml::DeliveredRequirementsToml; From 058d97c5dcf572ef7084e488bbb108d24aa19e54 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 7 Jul 2026 13:35:57 -0700 Subject: [PATCH 07/53] feat(core): emit canonical collab tool call items (#31300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR depends on [#31296](https://github.com/openai/codex/pull/31296) for the canonical-to-legacy event mappings. ## Description This PR makes the non-wait v1 collaboration tools—spawn, send input, resume, and close—emit canonical `TurnItem::CollabAgentToolCall` lifecycle instead of their legacy begin/end events directly. App-server v2 consumes the canonical collab items directly, ignores the mapped legacy events, and applies close-agent thread-watch cleanup from the completed item. ## Why These four tools share the same single-target lifecycle shape. Wait stays separate because it carries multi-target status snapshots and has its own status-shaping cleanup. ## What changed - Add shared helpers for emitting canonical collab tool-call lifecycle. - Migrate spawn, send input, resume, and close handlers. - Move close-agent watcher cleanup onto canonical completed collab items. --- .../app-server/src/bespoke_event_handling.rs | 36 ++++------ .../core/src/tools/handlers/multi_agents.rs | 25 ++++--- .../handlers/multi_agents/close_agent.rs | 72 +++++++++++-------- .../handlers/multi_agents/resume_agent.rs | 54 ++++++++------ .../tools/handlers/multi_agents/send_input.rs | 48 +++++++------ .../src/tools/handlers/multi_agents/spawn.rs | 59 +++++++++------ .../src/tools/handlers/multi_agents/wait.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 1 + 8 files changed, 174 insertions(+), 122 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 831715523b..2319cdacce 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -89,6 +89,7 @@ use codex_core::ThreadManager; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; +use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; @@ -831,15 +832,20 @@ pub(crate) async fn apply_bespoke_event_handling( // Deprecated MCP tool-call events are still fanned out for legacy clients. // App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead. } - msg @ (EventMsg::CollabAgentSpawnBegin(_) + EventMsg::CollabAgentSpawnBegin(_) | EventMsg::CollabAgentSpawnEnd(_) | EventMsg::CollabAgentInteractionBegin(_) | EventMsg::CollabAgentInteractionEnd(_) - | EventMsg::CollabWaitingBegin(_) - | EventMsg::CollabWaitingEnd(_) | EventMsg::CollabCloseBegin(_) + | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeBegin(_) - | EventMsg::CollabResumeEnd(_) + | EventMsg::CollabResumeEnd(_) => { + // Deprecated non-wait collaboration events are still fanned out for raw-event and + // rollout compatibility consumers. App-server v2 receives the canonical + // CollabAgentToolCall item lifecycle instead. + } + msg @ (EventMsg::CollabWaitingBegin(_) + | EventMsg::CollabWaitingEnd(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::PlanDelta(_) | EventMsg::ReasoningContentDelta(_) @@ -857,23 +863,6 @@ pub(crate) async fn apply_bespoke_event_handling( // rollout compatibility consumers. App-server v2 receives the canonical // SubAgentActivity item lifecycle instead. } - EventMsg::CollabCloseEnd(end_event) => { - if thread_manager - .get_thread(end_event.receiver_thread_id) - .await - .is_err() - { - thread_watch_manager - .remove_thread(&end_event.receiver_thread_id.to_string()) - .await; - } - let notification = item_event_to_server_notification( - EventMsg::CollabCloseEnd(end_event), - &conversation_id.to_string(), - &event_turn_id, - ); - outgoing.send_server_notification(notification).await; - } EventMsg::ContextCompacted(..) => { // Core still fans out this deprecated event for legacy clients; // v2 clients receive the canonical ContextCompaction item instead. @@ -1351,6 +1340,11 @@ async fn apply_canonical_item_completed_side_effects( ) .await; } + CoreTurnItem::CollabAgentToolCall(item) if item.tool == CoreCollabAgentTool::CloseAgent => { + for thread_id in &item.receiver_thread_ids { + remove_missing_thread_watch(thread_manager, thread_watch_manager, *thread_id).await; + } + } _ => {} } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 8bc96d19c2..c48c8b0b58 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -8,8 +8,6 @@ use crate::agent::AgentStatus; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::function_tool::FunctionCallError; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -20,17 +18,13 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_protocol::ThreadId; +use codex_protocol::items::CollabAgentTool; +use codex_protocol::items::CollabAgentToolCallItem; +use codex_protocol::items::CollabAgentToolCallStatus; +use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabResumeBeginEvent; -use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; @@ -91,6 +85,17 @@ mod send_input; mod spawn; pub(crate) mod wait; +pub(crate) fn collab_tool_call_status( + status: &AgentStatus, + receiver_thread_id: Option, +) -> CollabAgentToolCallStatus { + match status { + AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed, + _ if receiver_thread_id.is_some() => CollabAgentToolCallStatus::Completed, + _ => CollabAgentToolCallStatus::Failed, + } +} + #[cfg(test)] #[path = "multi_agents_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index d1017adb65..d905cf4b94 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -1,6 +1,5 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; @@ -44,15 +43,20 @@ async fn handle_close_agent( let known_agent = receiver_agent.is_some(); let receiver_agent = receiver_agent.unwrap_or_default(); session - .send_event( + .emit_turn_item_started( &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - receiver_thread_id: agent_id, - } - .into(), + receiver_thread_ids: vec![agent_id], + receiver_agents: Vec::new(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; let status = match session @@ -68,18 +72,24 @@ async fn handle_close_agent( Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; session - .send_event( + .emit_turn_item_completed( &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status: collab_tool_call_status(&status, Some(agent_id)), sender_thread_id: session.thread_id(), - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), + receiver_thread_ids: vec![agent_id], + receiver_agents: vec![CollabAgentRef { + thread_id: agent_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [(agent_id, status)].into_iter().collect(), + }), ) .await; return Err(collab_agent_error(agent_id, err)); @@ -90,18 +100,24 @@ async fn handle_close_agent( .map_err(|err| collab_agent_error(agent_id, err)) .map(|_| ()); session - .send_event( + .emit_turn_item_completed( &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::CloseAgent, + status: collab_tool_call_status(&status, Some(agent_id)), sender_thread_id: session.thread_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), + receiver_thread_ids: vec![agent_id], + receiver_agents: vec![CollabAgentRef { + thread_id: agent_id, + agent_nickname: receiver_agent.agent_nickname, + agent_role: receiver_agent.agent_role, + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [(agent_id, status.clone())].into_iter().collect(), + }), ) .await; result?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index fa464ddbbb..8e97b2afbd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -1,7 +1,8 @@ use super::*; use crate::agent::next_thread_spawn_depth; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; use std::sync::Arc; @@ -57,17 +58,24 @@ async fn handle_resume_agent( } session - .send_event( + .emit_turn_item_started( &turn, - CollabResumeBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver_thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; @@ -113,18 +121,24 @@ async fn handle_resume_agent( (receiver_agent, None) }; session - .send_event( + .emit_turn_item_completed( &turn, - CollabResumeEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::ResumeAgent, + status: collab_tool_call_status(&status, Some(receiver_thread_id)), sender_thread_id: session.thread_id(), - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver_thread_id, + agent_nickname: receiver_agent.agent_nickname, + agent_role: receiver_agent.agent_role, + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [(receiver_thread_id, status.clone())].into_iter().collect(), + }), ) .await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 375bb50ac7..649eaf1ce0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,7 +1,6 @@ use super::*; use crate::agent::control::render_input_preview; use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; pub(crate) struct Handler; @@ -67,16 +66,20 @@ impl Handler { .map_err(|err| collab_agent_error(receiver_thread_id, err))?; } session - .send_event( + .emit_turn_item_started( &turn, - CollabAgentInteractionBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - receiver_thread_id, - prompt: prompt.clone(), - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: Vec::new(), + prompt: Some(prompt.clone()), + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; let agent_control = session.services.agent_control.clone(); @@ -90,19 +93,24 @@ impl Handler { .get_status(receiver_thread_id) .await; session - .send_event( + .emit_turn_item_completed( &turn, - CollabAgentInteractionEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::SendInput, + status: collab_tool_call_status(&status, Some(receiver_thread_id)), sender_thread_id: session.thread_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - prompt, - status, - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver_thread_id, + agent_nickname: receiver_agent.agent_nickname, + agent_role: receiver_agent.agent_role, + }], + prompt: Some(prompt), + model: None, + reasoning_effort: None, + agents_states: [(receiver_thread_id, status)].into_iter().collect(), + }), ) .await; let submission_id = result?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 204385934e..e5923bab79 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -8,7 +8,6 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; #[derive(Default)] @@ -71,17 +70,20 @@ async fn handle_spawn_agent( )); } session - .send_event( + .emit_turn_item_started( &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), - } - .into(), + receiver_thread_ids: Vec::new(), + receiver_agents: Vec::new(), + prompt: Some(prompt.clone()), + model: Some(args.model.clone().unwrap_or_default()), + reasoning_effort: Some(args.reasoning_effort.clone().unwrap_or_default()), + agents_states: Default::default(), + }), ) .await; let mut config = @@ -177,22 +179,33 @@ async fn handle_spawn_agent( .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); + let receiver_thread_ids = new_thread_id.into_iter().collect(); + let receiver_agents = new_thread_id + .map(|thread_id| CollabAgentRef { + thread_id, + agent_nickname: new_agent_nickname, + agent_role: new_agent_role, + }) + .into_iter() + .collect(); + let agents_states = new_thread_id + .map(|thread_id| [(thread_id, status.clone())].into_iter().collect()) + .unwrap_or_default(); session - .send_event( + .emit_turn_item_completed( &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::SpawnAgent, + status: collab_tool_call_status(&status, new_thread_id), sender_thread_id: session.thread_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, - } - .into(), + receiver_thread_ids, + receiver_agents, + prompt: Some(prompt), + model: Some(effective_model), + reasoning_effort: Some(effective_reasoning_effort), + agents_states, + }), ) .await; let new_thread_id = result?.thread_id; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index be22b36908..8d0e8daf0a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -1,5 +1,6 @@ use super::*; use crate::agent::status::is_final; +use crate::session::session::Session; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1; use crate::turn_timing::now_unix_timestamp_ms; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index f313fcf0d9..c369fccbc7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -7,6 +7,7 @@ use crate::init_state_db; use crate::local_agent_graph_store_from_state_db; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; +use crate::session::turn_context::TurnContext; use crate::session_prefix::format_inter_agent_completion_message; use crate::thread_manager::thread_store_from_config; use crate::tools::context::ToolOutput; From 6b4882528eaf2d927904bba37c48e40d925c35d1 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 7 Jul 2026 13:59:02 -0700 Subject: [PATCH 08/53] feat(core): emit canonical collab wait items (#31301) This PR depends on [#31296](https://github.com/openai/codex/pull/31296) for the canonical-to-legacy event mappings. ## Description This PR makes the v1 and v2 wait paths emit canonical `TurnItem::CollabAgentToolCall` lifecycle instead of `CollabWaitingBegin` / `CollabWaitingEnd` directly. Both paths already used the same legacy waiting events before this PR. The v1 item carries receiver metadata and final agent statuses for its target agents; v2 waits for mailbox activity rather than specific agents, so it keeps those fields empty, matching the existing v2 legacy payload. App-server v2 consumes the canonical item directly and ignores the mapped legacy wait events. ## Why Wait is separate from the other collab tools because it is multi-target and has distinct timeout/status behavior. Keeping it last also lets this PR remove the old helper that only existed to shape legacy wait status entries in core. ## What changed - Emit canonical collab wait items from both v1 and v2 wait handlers. - Preserve receiver metadata and agent status snapshots on completed wait items. - Remove the old core helper for building legacy wait status entries. ## Follow-up The next stack PR, [#30188](https://github.com/openai/codex/pull/30188), writes canonical `TurnItem` values to paginated rollout files. --- .../app-server/src/bespoke_event_handling.rs | 52 ++++----- .../core/src/tools/handlers/multi_agents.rs | 2 - .../src/tools/handlers/multi_agents/wait.rs | 103 +++++++++++++----- .../src/tools/handlers/multi_agents_common.rs | 41 ------- .../src/tools/handlers/multi_agents_v2.rs | 5 +- .../tools/handlers/multi_agents_v2/wait.rs | 37 ++++--- 6 files changed, 124 insertions(+), 116 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2319cdacce..b6f47dfe82 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -823,30 +823,32 @@ pub(crate) async fn apply_bespoke_event_handling( on_request_permissions_response(pending_response, conversation, thread_state).await; }); } - EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) => { - // Deprecated dynamic-tool events are still fanned out for raw-event and rollout - // compatibility consumers. App-server v2 receives the canonical DynamicToolCall - // item lifecycle and dispatches client requests from canonical starts instead. - } - EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => { - // Deprecated MCP tool-call events are still fanned out for legacy clients. - // App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead. - } - EventMsg::CollabAgentSpawnBegin(_) + EventMsg::DynamicToolCallRequest(_) + | EventMsg::DynamicToolCallResponse(_) + | EventMsg::CollabAgentSpawnBegin(_) | EventMsg::CollabAgentSpawnEnd(_) | EventMsg::CollabAgentInteractionBegin(_) | EventMsg::CollabAgentInteractionEnd(_) + | EventMsg::CollabWaitingBegin(_) + | EventMsg::CollabWaitingEnd(_) | EventMsg::CollabCloseBegin(_) | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeBegin(_) - | EventMsg::CollabResumeEnd(_) => { - // Deprecated non-wait collaboration events are still fanned out for raw-event and - // rollout compatibility consumers. App-server v2 receives the canonical - // CollabAgentToolCall item lifecycle instead. + | EventMsg::CollabResumeEnd(_) + | EventMsg::SubAgentActivity(_) + | EventMsg::ExecCommandBegin(_) + | EventMsg::ExecCommandEnd(_) => { + // Deprecated item lifecycle events are still fanned out for raw-event and rollout + // compatibility consumers. + // App-server v2 receives canonical TurnItem lifecycle instead, and dispatches + // dynamic tool requests from canonical DynamicToolCall starts. } - msg @ (EventMsg::CollabWaitingBegin(_) - | EventMsg::CollabWaitingEnd(_) - | EventMsg::AgentMessageContentDelta(_) + EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => { + // Deprecated MCP tool-call events are still fanned out for raw-event and rollout + // compatibility consumers. + // App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead. + } + msg @ (EventMsg::AgentMessageContentDelta(_) | EventMsg::PlanDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) @@ -858,13 +860,9 @@ pub(crate) async fn apply_bespoke_event_handling( ); outgoing.send_server_notification(notification).await; } - EventMsg::SubAgentActivity(_) => { - // Deprecated sub-agent activity events are still fanned out for raw-event and - // rollout compatibility consumers. App-server v2 receives the canonical - // SubAgentActivity item lifecycle instead. - } EventMsg::ContextCompacted(..) => { - // Core still fans out this deprecated event for legacy clients; + // Core still fans out this deprecated event for raw-event and rollout compatibility + // consumers; // v2 clients receive the canonical ContextCompaction item instead. } EventMsg::DeprecationNotice(event) => { @@ -1096,14 +1094,10 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => { - // Core still fans out these deprecated events for legacy clients; + // Core still fans out these deprecated events for raw-event and rollout compatibility + // consumers; // v2 clients receive the canonical FileChange item instead. } - EventMsg::ExecCommandBegin(_) | EventMsg::ExecCommandEnd(_) => { - // Deprecated command-execution events are still fanned out for raw-event and rollout - // compatibility consumers. App-server v2 receives the canonical CommandExecution - // item lifecycle instead. - } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { let notification = item_event_to_server_notification( EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event), diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index c48c8b0b58..87afc2b7cf 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -25,8 +25,6 @@ use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::CollabAgentRef; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 8d0e8daf0a..75fe4f0f9f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -3,7 +3,6 @@ use crate::agent::status::is_final; use crate::session::session::Session; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; use futures::FutureExt; @@ -98,16 +97,20 @@ impl Handler { }; session - .send_event( + .emit_turn_item_started( &turn, - CollabWaitingBeginEvent { - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, receiver_thread_ids: receiver_thread_ids.clone(), receiver_agents: receiver_agents.clone(), - call_id: call_id.clone(), - } - .into(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; @@ -129,19 +132,20 @@ impl Handler { let mut statuses = HashMap::with_capacity(1); statuses.insert(*id, session.services.agent_control.get_status(*id).await); session - .send_event( + .emit_turn_item_completed( &turn, - CollabWaitingEndEvent { + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::Wait, + status: wait_tool_call_status(&statuses), sender_thread_id: session.thread_id, - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - agent_statuses: build_wait_agent_statuses( - &statuses, - &receiver_agents, - ), - statuses, - } - .into(), + receiver_thread_ids: statuses.keys().copied().collect(), + receiver_agents: wait_receiver_agents(&statuses, &receiver_agents), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: statuses, + }), ) .await; return Err(collab_agent_error(*id, err)); @@ -183,7 +187,6 @@ impl Handler { let timed_out = statuses.is_empty(); let statuses_by_id = statuses.clone().into_iter().collect::>(); - let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); let result = WaitAgentResult { status: statuses .into_iter() @@ -198,16 +201,20 @@ impl Handler { }; session - .send_event( + .emit_turn_item_completed( &turn, - CollabWaitingEndEvent { + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::Wait, + status: wait_tool_call_status(&statuses_by_id), sender_thread_id: session.thread_id, - call_id, - completed_at_ms: now_unix_timestamp_ms(), - agent_statuses, - statuses: statuses_by_id, - } - .into(), + receiver_thread_ids: statuses_by_id.keys().copied().collect(), + receiver_agents: wait_receiver_agents(&statuses_by_id, &receiver_agents), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: statuses_by_id, + }), ) .await; @@ -215,6 +222,48 @@ impl Handler { } } +fn wait_tool_call_status(statuses: &HashMap) -> CollabAgentToolCallStatus { + if statuses + .values() + .any(|status| matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound)) + { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + } +} + +fn wait_receiver_agents( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut agents = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if statuses.contains_key(&receiver_agent.thread_id) { + agents.push(receiver_agent.clone()); + } + } + + let mut extras = statuses + .keys() + .filter(|thread_id| !seen.contains_key(thread_id)) + .map(|thread_id| CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }) + .collect::>(); + extras.sort_by_key(|agent| agent.thread_id.to_string()); + agents.extend(extras); + agents +} + impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 12ec89d651..287e4b30d5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -1,4 +1,3 @@ -use crate::agent::AgentStatus; use crate::config::Config; use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS; @@ -16,14 +15,11 @@ use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::protocol::CollabAgentRef; -use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Serialize; use serde_json::Value as JsonValue; -use std::collections::HashMap; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; @@ -71,43 +67,6 @@ where }) } -pub(crate) fn build_wait_agent_statuses( - statuses: &HashMap, - receiver_agents: &[CollabAgentRef], -) -> Vec { - if statuses.is_empty() { - return Vec::new(); - } - - let mut entries = Vec::with_capacity(statuses.len()); - let mut seen = HashMap::with_capacity(receiver_agents.len()); - for receiver_agent in receiver_agents { - seen.insert(receiver_agent.thread_id, ()); - if let Some(status) = statuses.get(&receiver_agent.thread_id) { - entries.push(CollabAgentStatusEntry { - thread_id: receiver_agent.thread_id, - agent_nickname: receiver_agent.agent_nickname.clone(), - agent_role: receiver_agent.agent_role.clone(), - status: status.clone(), - }); - } - } - - let mut extras = statuses - .iter() - .filter(|(thread_id, _)| !seen.contains_key(thread_id)) - .map(|(thread_id, status)| CollabAgentStatusEntry { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - status: status.clone(), - }) - .collect::>(); - extras.sort_by_key(|entry| entry.thread_id.to_string()); - entries.extend(extras); - entries -} - pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError { match err { CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 9473908a46..d1446ec621 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -12,12 +12,13 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_protocol::AgentPath; +use codex_protocol::items::CollabAgentTool; +use codex_protocol::items::CollabAgentToolCallItem; +use codex_protocol::items::CollabAgentToolCallStatus; use codex_protocol::items::SubAgentActivityItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SubAgentActivityKind; use codex_tools::ToolName; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index e97f47238b..4e2eced260 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -2,7 +2,6 @@ use super::*; use crate::session::InputQueueActivity; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; use std::collections::HashMap; use std::time::Duration; @@ -76,16 +75,20 @@ impl Handler { .await; session - .send_event( + .emit_turn_item_started( &turn, - CollabWaitingBeginEvent { - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, receiver_thread_ids: Vec::new(), receiver_agents: Vec::new(), - call_id: call_id.clone(), - } - .into(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; @@ -94,16 +97,20 @@ impl Handler { let result = WaitAgentResult::from_outcome(outcome); session - .send_event( + .emit_turn_item_completed( &turn, - CollabWaitingEndEvent { + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Completed, sender_thread_id: session.thread_id, - call_id, - completed_at_ms: now_unix_timestamp_ms(), - agent_statuses: Vec::new(), - statuses: HashMap::new(), - } - .into(), + receiver_thread_ids: Vec::new(), + receiver_agents: Vec::new(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }), ) .await; From 172ab264bd61b8a2dc706fa5d5443b8130c74de7 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Tue, 7 Jul 2026 14:26:03 -0700 Subject: [PATCH 09/53] fix: retry rejected previous-model compaction with selected model (#30319) ## Why Pre-sampling compaction intentionally uses the previous turn's model when the compaction compatibility hash changes or when switching to a model with a smaller context window. This keeps compaction aligned with the settings that produced the history, but it can block the next turn when a resumed ChatGPT thread still references a model slug that has since been retired. The Codex backend rejects that compaction request before the user's currently selected model gets a chance to sample. This PR lets those threads recover without changing previous-model compaction behavior for API-key authentication or custom providers. It is stacked on #31316, which is a behavior-preserving extraction of the individual remote compaction attempts; this PR contains the fallback behavior. ## What changed - For automatic previous-model compaction, capture the selected model's request context when using ChatGPT authentication with the OpenAI provider and the selected model differs from the previous model. - If the previous-model attempt returns an `InvalidRequest`, retry compaction once with the selected model for both `/responses/compact` and Responses Compaction V2. - Complete history processing, lifecycle events, and token accounting with the context of the model that successfully compacted the thread. - If the fallback also fails, return the original previous-model error so the retry does not change the user-visible failure. - Record fallback attempts with reason, implementation, and outcome telemetry. - Leave API-key authentication, custom providers, same-model turns, and non-`InvalidRequest` failures on their existing paths. ## Testing - `just test -p codex-core -E 'test(pre_sampling_compact) | test(model_unavailable_error)'` (10 tests) - Added integration coverage for a resumed thread whose model was renamed, a model downshift using Responses Compaction V2, and API-key authentication with a custom provider. --- codex-rs/core/src/compact_model_fallback.rs | 49 +++ codex-rs/core/src/compact_remote.rs | 109 +++-- codex-rs/core/src/compact_remote_v2.rs | 117 +++-- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/responses_metadata.rs | 16 + codex-rs/core/src/session/turn.rs | 46 +- codex-rs/core/tests/common/responses.rs | 39 ++ codex-rs/core/tests/suite/compact.rs | 449 ++++++++++++++++++++ codex-rs/rollout-trace/src/thread_tests.rs | 34 ++ 9 files changed, 797 insertions(+), 63 deletions(-) create mode 100644 codex-rs/core/src/compact_model_fallback.rs diff --git a/codex-rs/core/src/compact_model_fallback.rs b/codex-rs/core/src/compact_model_fallback.rs new file mode 100644 index 0000000000..10d1ce743a --- /dev/null +++ b/codex-rs/core/src/compact_model_fallback.rs @@ -0,0 +1,49 @@ +use codex_analytics::CompactionImplementation; +use codex_analytics::CompactionReason; +use codex_otel::SessionTelemetry; +use codex_protocol::error::CodexErr; +use tracing::warn; + +pub(crate) fn record_model_fallback( + session_telemetry: &SessionTelemetry, + previous_model: &str, + current_model: &str, + reason: CompactionReason, + implementation: CompactionImplementation, + fallback_error: Option<&CodexErr>, +) { + let reason_tag = match reason { + CompactionReason::UserRequested => "user_requested", + CompactionReason::ContextLimit => "context_limit", + CompactionReason::ModelDownshift => "model_downshift", + CompactionReason::CompHashChanged => "comp_hash_changed", + }; + let implementation_tag = match implementation { + CompactionImplementation::Responses => "responses", + CompactionImplementation::ResponsesCompactionV2 => "responses_compaction_v2", + CompactionImplementation::ResponsesCompact => "responses_compact", + }; + let outcome = if fallback_error.is_none() { + "succeeded" + } else { + "failed" + }; + session_telemetry.counter( + "codex.compaction.model_fallback", + /*inc*/ 1, + &[ + ("reason", reason_tag), + ("implementation", implementation_tag), + ("outcome", outcome), + ], + ); + warn!( + previous_model, + current_model, + ?reason, + ?implementation, + outcome, + ?fallback_error, + "previous-model compaction failed; retried with current model" + ); +} diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 8964b9aa42..4b1db93555 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -7,6 +7,7 @@ use crate::compact::InitialContextInjection; use crate::compact::build_compaction_initial_context; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; +use crate::compact_model_fallback::record_model_fallback; use crate::context::world_state::WorldState; use crate::context_manager::ContextManager; use crate::hook_runtime::PostCompactHookOutcome; @@ -45,19 +46,25 @@ const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, step_context: Arc, + fallback_step_context: Option>, turn_state: Arc>, initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Auto, + reason, + CompactionImplementation::ResponsesCompact, + phase, + ); run_remote_compact_task_inner( &sess, &step_context, + fallback_step_context.as_ref(), Some(turn_state), initial_context_injection, - CompactionTrigger::Auto, - reason, - phase, + compaction_metadata, ) .await?; Ok(()) @@ -78,14 +85,19 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Manual, + CompactionReason::UserRequested, + CompactionImplementation::ResponsesCompact, + CompactionPhase::StandaloneTurn, + ); run_remote_compact_task_inner( &sess, &step_context, + /*fallback_step_context*/ None, /*turn_state*/ None, InitialContextInjection::DoNotInject, - CompactionTrigger::Manual, - CompactionReason::UserRequested, - CompactionPhase::StandaloneTurn, + compaction_metadata, ) .await?; Ok(()) @@ -94,19 +106,16 @@ pub(crate) async fn run_remote_compact_task( async fn run_remote_compact_task_inner( sess: &Arc, step_context: &Arc, + fallback_step_context: Option<&Arc>, turn_state: Option>>, initial_context_injection: InitialContextInjection, - trigger: CompactionTrigger, - reason: CompactionReason, - phase: CompactionPhase, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let turn_context = &step_context.turn; - let compaction_metadata = CompactionTurnMetadata::new( - trigger, - reason, - CompactionImplementation::ResponsesCompact, - phase, - ); + let trigger = compaction_metadata.trigger(); + let reason = compaction_metadata.reason(); + let implementation = compaction_metadata.implementation(); + let phase = compaction_metadata.phase(); let mut analytics_details = CompactionAnalyticsDetails { active_context_tokens_before: Some(sess.get_total_token_usage().await), ..Default::default() @@ -116,7 +125,7 @@ async fn run_remote_compact_task_inner( turn_context.as_ref(), trigger, reason, - CompactionImplementation::ResponsesCompact, + implementation, phase, ) .await; @@ -139,6 +148,7 @@ async fn run_remote_compact_task_inner( let result = run_remote_compact_task_inner_impl( sess, step_context, + fallback_step_context, turn_state, initial_context_injection, compaction_metadata, @@ -173,6 +183,7 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, step_context: &Arc, + fallback_step_context: Option<&Arc>, turn_state: Option>>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, @@ -180,33 +191,75 @@ async fn run_remote_compact_task_inner_impl( ) -> CodexResult<()> { let turn_context = &step_context.turn; let context_compaction_item = ContextCompactionItem::new(); + let compaction_id = context_compaction_item.id.clone(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, // endpoint attempts, and the installed history checkpoint all have one join key. let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( turn_context.sub_id.as_str(), - context_compaction_item.id.as_str(), + compaction_id.as_str(), turn_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); let compaction_item = TurnItem::ContextCompaction(context_compaction_item); sess.emit_turn_item_started(turn_context, &compaction_item) .await; - let RemoteCompactAttempt { - new_history, - trace_input_history, - } = run_remote_compact_attempt( + let attempt = run_remote_compact_attempt( sess, step_context, - turn_state, + turn_state.clone(), &compaction_trace, compaction_metadata, analytics_details, ) - .await?; + .await; + let (attempt, compaction_turn_context) = match attempt { + Ok(attempt) => (attempt, turn_context), + Err(error) => { + let Some(fallback_step_context) = fallback_step_context else { + return Err(error); + }; + if !matches!(&error, CodexErr::InvalidRequest(_)) { + return Err(error); + } + let fallback_turn_context = &fallback_step_context.turn; + let fallback_compaction_trace = + sess.services.rollout_thread_trace.compaction_trace_context( + fallback_turn_context.sub_id.as_str(), + compaction_id.as_str(), + fallback_turn_context.model_info.slug.as_str(), + fallback_turn_context.provider.info().name.as_str(), + ); + let fallback_result = run_remote_compact_attempt( + sess, + fallback_step_context, + turn_state, + &fallback_compaction_trace, + compaction_metadata, + analytics_details, + ) + .await; + record_model_fallback( + &sess.services.session_telemetry, + turn_context.model_info.slug.as_str(), + fallback_turn_context.model_info.slug.as_str(), + compaction_metadata.reason(), + compaction_metadata.implementation(), + fallback_result.as_ref().err(), + ); + match fallback_result { + Ok(attempt) => (attempt, fallback_turn_context), + Err(_) => return Err(error), + } + } + }; + let RemoteCompactAttempt { + new_history, + trace_input_history, + } = attempt; let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let (new_history, world_state_baseline) = process_compacted_history( sess.as_ref(), - turn_context.as_ref(), + compaction_turn_context.as_ref(), new_history, &initial_context_injection, ) @@ -215,7 +268,7 @@ async fn run_remote_compact_task_inner_impl( let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, InitialContextInjection::BeforeLastUserMessage(_) => { - Some(turn_context.to_turn_context_item()) + Some(compaction_turn_context.to_turn_context_item()) } }; let compacted_item = CompactedItem { @@ -234,16 +287,16 @@ async fn run_remote_compact_task_inner_impl( replacement_history: &new_history, }); sess.replace_compacted_history( - turn_context.as_ref(), + compaction_turn_context.as_ref(), new_history, reference_context_item, world_state_baseline, compacted_item, ) .await; - sess.recompute_token_usage(turn_context).await; + sess.recompute_token_usage(compaction_turn_context).await; - sess.emit_turn_item_completed(turn_context, compaction_item) + sess.emit_turn_item_completed(compaction_turn_context, compaction_item) .await; Ok(()) } diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 8105f63b7a..1571602cc1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -8,6 +8,7 @@ use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; +use crate::compact_model_fallback::record_model_fallback; use crate::compact_remote::process_compacted_history; use crate::compact_remote::should_keep_compacted_history_item; use crate::hook_runtime::PostCompactHookOutcome; @@ -57,19 +58,25 @@ const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, step_context: Arc, + fallback_step_context: Option>, client_session: &mut ModelClientSession, initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Auto, + reason, + CompactionImplementation::ResponsesCompactionV2, + phase, + ); run_remote_compact_task_inner( &sess, &step_context, + fallback_step_context.as_ref(), Some(client_session), initial_context_injection, - CompactionTrigger::Auto, - reason, - phase, + compaction_metadata, ) .await } @@ -89,14 +96,19 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Manual, + CompactionReason::UserRequested, + CompactionImplementation::ResponsesCompactionV2, + CompactionPhase::StandaloneTurn, + ); run_remote_compact_task_inner( &sess, &step_context, + /*fallback_step_context*/ None, /*client_session*/ None, InitialContextInjection::DoNotInject, - CompactionTrigger::Manual, - CompactionReason::UserRequested, - CompactionPhase::StandaloneTurn, + compaction_metadata, ) .await } @@ -104,19 +116,16 @@ pub(crate) async fn run_remote_compact_task( async fn run_remote_compact_task_inner( sess: &Arc, step_context: &Arc, + fallback_step_context: Option<&Arc>, client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, - trigger: CompactionTrigger, - reason: CompactionReason, - phase: CompactionPhase, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let turn_context = &step_context.turn; - let compaction_metadata = CompactionTurnMetadata::new( - trigger, - reason, - CompactionImplementation::ResponsesCompactionV2, - phase, - ); + let trigger = compaction_metadata.trigger(); + let reason = compaction_metadata.reason(); + let implementation = compaction_metadata.implementation(); + let phase = compaction_metadata.phase(); let mut analytics_details = CompactionAnalyticsDetails { active_context_tokens_before: Some(sess.get_total_token_usage().await), ..Default::default() @@ -126,7 +135,7 @@ async fn run_remote_compact_task_inner( turn_context.as_ref(), trigger, reason, - CompactionImplementation::ResponsesCompactionV2, + implementation, phase, ) .await; @@ -149,6 +158,7 @@ async fn run_remote_compact_task_inner( let result = run_remote_compact_task_inner_impl( sess, step_context, + fallback_step_context, client_session, initial_context_injection, compaction_metadata, @@ -186,16 +196,18 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, step_context: &Arc, - client_session: Option<&mut ModelClientSession>, + fallback_step_context: Option<&Arc>, + mut client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let turn_context = &step_context.turn; let context_compaction_item = ContextCompactionItem::new(); + let compaction_id = context_compaction_item.id.clone(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( turn_context.sub_id.as_str(), - context_compaction_item.id.as_str(), + compaction_id.as_str(), turn_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); @@ -203,21 +215,62 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_started(turn_context, &compaction_item) .await; + let attempt = run_remote_compact_v2_attempt( + sess, + step_context, + client_session.as_deref_mut(), + &compaction_trace, + compaction_metadata, + analytics_details, + ) + .await; + let (attempt, compaction_turn_context) = match attempt { + Ok(attempt) => (attempt, turn_context), + Err(error) => { + let Some(fallback_step_context) = fallback_step_context else { + return Err(error); + }; + if !matches!(&error, CodexErr::InvalidRequest(_)) { + return Err(error); + } + let fallback_turn_context = &fallback_step_context.turn; + let fallback_compaction_trace = + sess.services.rollout_thread_trace.compaction_trace_context( + fallback_turn_context.sub_id.as_str(), + compaction_id.as_str(), + fallback_turn_context.model_info.slug.as_str(), + fallback_turn_context.provider.info().name.as_str(), + ); + let fallback_result = run_remote_compact_v2_attempt( + sess, + fallback_step_context, + client_session, + &fallback_compaction_trace, + compaction_metadata, + analytics_details, + ) + .await; + record_model_fallback( + &sess.services.session_telemetry, + turn_context.model_info.slug.as_str(), + fallback_turn_context.model_info.slug.as_str(), + compaction_metadata.reason(), + compaction_metadata.implementation(), + fallback_result.as_ref().err(), + ); + match fallback_result { + Ok(attempt) => (attempt, fallback_turn_context), + Err(_) => return Err(error), + } + } + }; let RemoteCompactV2Attempt { trace_input_history, prompt_input, compaction_output, token_usage, owned_client_session: _owned_client_session, - } = run_remote_compact_v2_attempt( - sess, - step_context, - client_session, - &compaction_trace, - compaction_metadata, - analytics_details, - ) - .await?; + } = attempt; if let Some(token_usage) = token_usage { sess.record_rollout_budget_usage(&token_usage)?; analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); @@ -230,7 +283,7 @@ async fn run_remote_compact_task_inner_impl( let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let (new_history, world_state_baseline) = process_compacted_history( sess.as_ref(), - turn_context.as_ref(), + compaction_turn_context.as_ref(), compacted_history, &initial_context_injection, ) @@ -239,7 +292,7 @@ async fn run_remote_compact_task_inner_impl( let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, InitialContextInjection::BeforeLastUserMessage(_) => { - Some(turn_context.to_turn_context_item()) + Some(compaction_turn_context.to_turn_context_item()) } }; let compacted_item = CompactedItem { @@ -255,16 +308,16 @@ async fn run_remote_compact_task_inner_impl( replacement_history: &new_history, }); sess.replace_compacted_history( - turn_context.as_ref(), + compaction_turn_context.as_ref(), new_history, reference_context_item, world_state_baseline, compacted_item, ) .await; - sess.recompute_token_usage(turn_context).await; + sess.recompute_token_usage(compaction_turn_context).await; - sess.emit_turn_item_completed(turn_context, compaction_item) + sess.emit_turn_item_completed(compaction_turn_context, compaction_item) .await; Ok(()) } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 91ccbd6e63..e57288f05d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -19,6 +19,7 @@ pub use responses_metadata::CodexResponsesMetadata; pub use session::SteerInputError; pub use turn_metadata::detached_memory_responses_metadata; mod codex_thread; +mod compact_model_fallback; mod compact_remote; mod compact_remote_v2; mod compact_token_budget; diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 1852c4bea6..c76d3078cd 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -93,6 +93,22 @@ impl CompactionTurnMetadata { strategy: CompactionStrategy::Memento, } } + + pub(crate) fn trigger(self) -> CompactionTrigger { + self.trigger + } + + pub(crate) fn reason(self) -> CompactionReason { + self.reason + } + + pub(crate) fn implementation(self) -> CompactionImplementation { + self.implementation + } + + pub(crate) fn phase(self) -> CompactionPhase { + self.phase + } } #[derive(Clone, Copy, Debug)] diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 708f42f0ff..e231f6af86 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -349,6 +349,7 @@ pub(crate) async fn run_turn( if let Err(err) = run_auto_compact( &sess, Arc::clone(&step_context), + /*fallback_step_context*/ None, &mut client_session, InitialContextInjection::BeforeLastUserMessage(Arc::clone(&world_state)), CompactionReason::ContextLimit, @@ -810,6 +811,7 @@ async fn run_pre_sampling_compact( run_auto_compact( sess, step_context, + /*fallback_step_context*/ None, client_session, InitialContextInjection::DoNotInject, CompactionReason::ContextLimit, @@ -828,6 +830,28 @@ fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool { .is_some_and(|(previous, current)| previous != current) } +/// Captures the current model's request-scoped state for retrying previous-model compaction. +/// +/// Returns `None` when the active authentication does not use the Codex backend, the provider is +/// not OpenAI, or the previous and current model are the same. +async fn capture_current_model_fallback_step_context( + sess: &Arc, + turn_context: &Arc, + previous_model: &str, +) -> Option> { + let uses_codex_backend = turn_context + .auth_manager + .as_deref() + .is_some_and(codex_login::AuthManager::current_auth_uses_codex_backend); + if !uses_codex_backend + || !turn_context.provider.info().is_openai() + || previous_model == turn_context.model_info.slug + { + return None; + } + Some(sess.capture_step_context(Arc::clone(turn_context)).await) +} + /// Runs pre-sampling compaction against the previous model when its compaction compatibility /// hash changed or when switching to a smaller context-window model. /// @@ -844,20 +868,27 @@ async fn maybe_run_previous_model_inline_compact( previous_turn_settings.comp_hash.as_deref(), turn_context.model_info.comp_hash.as_deref(), ); + let previous_model = previous_turn_settings.model; let previous_model_turn_context = Arc::new( turn_context - .with_model(previous_turn_settings.model, &sess.services.models_manager) + .with_model(previous_model.clone(), &sess.services.models_manager) .await, ); if should_compact_for_comp_hash_change { - // This pre-turn request needs a step built from the previous model's turn context. let step_context = sess .capture_step_context(Arc::clone(&previous_model_turn_context)) .await; + let fallback_step_context = capture_current_model_fallback_step_context( + sess, + turn_context, + previous_model.as_str(), + ) + .await; run_auto_compact( sess, step_context, + fallback_step_context, client_session, InitialContextInjection::DoNotInject, CompactionReason::CompHashChanged, @@ -892,13 +923,19 @@ async fn maybe_run_previous_model_inline_compact( && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - // This pre-turn request needs a step built from the previous model's turn context. let step_context = sess .capture_step_context(Arc::clone(&previous_model_turn_context)) .await; + let fallback_step_context = capture_current_model_fallback_step_context( + sess, + turn_context, + previous_model.as_str(), + ) + .await; run_auto_compact( sess, step_context, + fallback_step_context, client_session, InitialContextInjection::DoNotInject, CompactionReason::ModelDownshift, @@ -917,6 +954,7 @@ async fn maybe_run_previous_model_inline_compact( async fn run_auto_compact( sess: &Arc, step_context: Arc, + fallback_step_context: Option>, client_session: &mut ModelClientSession, initial_context_injection: InitialContextInjection, reason: CompactionReason, @@ -949,6 +987,7 @@ async fn run_auto_compact( run_inline_remote_auto_compact_task_v2( Arc::clone(sess), step_context, + fallback_step_context, client_session, initial_context_injection, reason, @@ -965,6 +1004,7 @@ async fn run_auto_compact( run_inline_remote_auto_compact_task( Arc::clone(sess), step_context, + fallback_step_context, client_session.turn_state(), initial_context_injection, reason, diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 9569df1c90..1ab3a70cbb 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -1569,6 +1569,45 @@ pub async fn mount_response_sequence( response_mock } +/// Mounts a sequence of responses for each POST to `/v1/responses/compact`. +/// Panics if more requests are received than responses provided. +pub async fn mount_compact_response_sequence( + server: &MockServer, + responses: Vec, +) -> ResponseMock { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + struct SeqResponder { + num_calls: AtomicUsize, + responses: Vec, + } + + impl Respond for SeqResponder { + fn respond(&self, _: &wiremock::Request) -> ResponseTemplate { + let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst); + self.responses + .get(call_num) + .expect("missing response for compact call") + .clone() + } + } + + let num_calls = responses.len(); + let responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + let (mock, response_mock) = compact_mock(); + mock.respond_with(responder) + .up_to_n_times(num_calls as u64) + .expect(num_calls as u64) + .mount(server) + .await; + response_mock +} + /// Validate invariants on the request body sent to `/v1/responses`. /// /// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`. diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 74c28664f7..fdcd4c0565 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -48,6 +48,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::mount_compact_json_once; +use core_test_support::responses::mount_compact_response_sequence; use core_test_support::responses::mount_response_sequence; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; @@ -274,6 +275,20 @@ fn non_openai_model_provider(server: &MockServer) -> ModelProviderInfo { provider } +fn openai_model_provider(server: &MockServer) -> ModelProviderInfo { + let mut provider = + built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["openai"].clone(); + provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; + provider +} + +fn invalid_request_response(message: impl Into) -> wiremock::ResponseTemplate { + wiremock::ResponseTemplate::new(/*status*/ 400).set_body_json(json!({ + "detail": message.into(), + })) +} + fn write_global_file( home: &TempDir, filename: &str, @@ -421,6 +436,7 @@ async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc compact_completed_id = Some(event.id.clone()), + EventMsg::Error(error) => panic!("unexpected compaction error: {error:?}"), EventMsg::TurnComplete(_) => turn_completed_id = Some(event.id.clone()), _ => {} } @@ -2276,6 +2292,439 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_falls_back_from_retired_previous_model_after_rename() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let retired_model = "gpt-5.5"; + let previous_model_family = "gpt-5.5"; + let renamed_model = "gpt-5.6"; + let mut previous_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-a")); + previous_model_info.slug = previous_model_family.to_string(); + let mut renamed_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-b")); + renamed_model_info.slug = renamed_model.to_string(); + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![previous_model_info, renamed_model_info], + }, + ) + .await; + + let request_log = mount_response_sequence( + &server, + vec![ + sse_response(sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ])), + invalid_request_response(format!( + "The '{retired_model}' model is not supported when using Codex with a ChatGPT account." + )), + sse_response(sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "RETIRED_MODEL_SUMMARY", + } + }), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ])), + sse_response(sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ])), + ], + ) + .await; + + let model_provider = openai_model_provider(&server); + let mut initial_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let initial = initial_builder + .build(&server) + .await + .expect("build initial test codex"); + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(disabled_permission_user_turn( + "before switch", + initial.cwd.path().to_path_buf(), + retired_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + initial + .codex + .submit(Op::Shutdown) + .await + .expect("shutdown initial session"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + let model_provider = openai_model_provider(&server); + let mut resumed_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let resumed = resumed_builder + .resume(&server, home, rollout_path) + .await + .expect("resume codex"); + + resumed + .codex + .submit(disabled_permission_user_turn( + "after switch", + resumed.cwd.path().to_path_buf(), + renamed_model.to_string(), + )) + .await + .expect("submit renamed-model turn"); + assert_compaction_uses_turn_lifecycle_id(&resumed.codex).await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 4, + "the renamed-model turn should retry compaction and then sample" + ); + assert_eq!( + requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + requests[1].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + requests[2].body_json()["model"].as_str(), + Some(renamed_model) + ); + assert_eq!( + requests[3].body_json()["model"].as_str(), + Some(renamed_model) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_falls_back_after_previous_model_invalid_request_on_downshift() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let retired_model = "gpt-5.6"; + let previous_model_family = "gpt-5.6"; + let next_model = "gpt-5.5"; + let mut previous_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 273_000); + previous_model_info.slug = previous_model_family.to_string(); + let mut next_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 125_000); + next_model_info.slug = next_model.to_string(); + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![previous_model_info, next_model_info], + }, + ) + .await; + + let request_log = mount_response_sequence( + &server, + vec![ + sse_response(sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 120_000), + ])), + invalid_request_response("previous-model compaction was rejected"), + sse_response(sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "DOWNSHIFT_SUMMARY", + } + }), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ])), + sse_response(sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ])), + ], + ) + .await; + + let model_provider = openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.enable(Feature::RemoteCompactionV2); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + retired_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit smaller-model turn"); + assert_compaction_uses_turn_lifecycle_id(&test.codex).await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 4, + "the smaller-model turn should retry compaction and then sample" + ); + assert_eq!( + requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + requests[1].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!(requests[2].body_json()["model"].as_str(), Some(next_model)); + assert_eq!(requests[3].body_json()["model"].as_str(), Some(next_model)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_legacy_remote_compact_falls_back_after_previous_model_invalid_request() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let retired_model = "gpt-5.6"; + let previous_model_family = "gpt-5.6"; + let next_model = "gpt-5.5"; + let mut previous_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 273_000); + previous_model_info.slug = previous_model_family.to_string(); + let mut next_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 125_000); + next_model_info.slug = next_model.to_string(); + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![previous_model_info, next_model_info], + }, + ) + .await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 120_000), + ]), + sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + let compact_request_log = mount_compact_response_sequence( + &server, + vec![ + invalid_request_response("previous-model compaction was rejected"), + wiremock::ResponseTemplate::new(/*status*/ 200) + .insert_header("content-type", "application/json") + .set_body_json(json!({ + "output": [{ + "type": "compaction", + "encrypted_content": "DOWNSHIFT_SUMMARY", + }], + })), + ], + ) + .await; + + let model_provider = openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.disable(Feature::RemoteCompactionV2); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + retired_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit smaller-model turn"); + assert_compaction_uses_turn_lifecycle_id(&test.codex).await; + + let requests = request_log.requests(); + let compact_requests = compact_request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!(requests.len(), 2); + assert_eq!(compact_requests.len(), 2); + assert_eq!( + requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + compact_requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + compact_requests[1].body_json()["model"].as_str(), + Some(next_model) + ); + assert_eq!(requests[1].body_json()["model"].as_str(), Some(next_model)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_keeps_unknown_previous_model_for_api_key_auth_and_custom_provider() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "custom/gpt-5.5"; + let previous_model_family = "gpt-5.5"; + let next_model = "gpt-5.6"; + let mut previous_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-a")); + previous_model_info.slug = previous_model_family.to_string(); + let mut next_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-b")); + next_model_info.slug = next_model.to_string(); + + let model_catalog = ModelsResponse { + models: vec![previous_model_info, next_model_info], + }; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "CUSTOM_MODEL_SUMMARY"), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ]), + sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + config.model_catalog = Some(model_catalog); + set_test_compact_prompt(config); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit next-model turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + assert_pre_sampling_switch_compaction_requests( + &requests[0].body_json(), + &requests[1].body_json(), + &requests[2].body_json(), + previous_model, + next_model, + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { skip_if_no_network!(); diff --git a/codex-rs/rollout-trace/src/thread_tests.rs b/codex-rs/rollout-trace/src/thread_tests.rs index 6421b29af3..6a41e0beeb 100644 --- a/codex-rs/rollout-trace/src/thread_tests.rs +++ b/codex-rs/rollout-trace/src/thread_tests.rs @@ -164,6 +164,40 @@ fn disabled_thread_context_accepts_trace_calls_without_writing() -> anyhow::Resu Ok(()) } +#[test] +fn compaction_contexts_share_identity_across_models() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let thread_id = ThreadId::new(); + let thread_trace = + ThreadTraceContext::start_root_in_root_for_test(temp.path(), minimal_metadata(thread_id))?; + thread_trace.record_codex_turn_started("turn-1"); + + for model in ["gpt-previous", "gpt-selected"] { + let compaction_trace = + thread_trace.compaction_trace_context("turn-1", "compaction-1", model, "test-provider"); + compaction_trace + .start_attempt(&serde_json::json!({ "model": model })) + .record_failed("test failure"); + } + + let replayed = replay_bundle(&single_bundle_dir(temp.path())?)?; + let mut attempts = replayed + .compaction_requests + .values() + .map(|attempt| (attempt.model.clone(), attempt.compaction_id.clone())) + .collect::>(); + attempts.sort(); + assert_eq!( + attempts, + vec![ + ("gpt-previous".to_string(), "compaction-1".to_string()), + ("gpt-selected".to_string(), "compaction-1".to_string()), + ] + ); + + Ok(()) +} + #[test] fn protocol_wrapper_records_selected_events_as_raw_payloads() -> anyhow::Result<()> { let temp = TempDir::new()?; From 9deb4f9c86426c40ba1e189831d7bc3634dd7b94 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 17:50:39 -0400 Subject: [PATCH 10/53] Handle mixed-case URLs in Windows command safety (#30879) ## Summary - recognize embedded HTTP(S) URL prefixes case-insensitively in Windows dangerous-command detection - add regression coverage for uppercase and mixed-case schemes inside `Start-Process` invocations ## Why PowerShell and URL parsing treat schemes case-insensitively, but the pre-parser only searched for lowercase `http://` and `https://`. When a URL appeared in the same shlex token as surrounding PowerShell syntax, such as `Start-Process('HTTPS://example.com');`, the prefix was not stripped and the command was incorrectly classified as not dangerous. Validated with the scoped `codex-shell-command` suite (138 tests) and a direct classifier reproduction that failed before the change and passed afterward. --- .../windows_dangerous_commands.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/codex-rs/shell-command/src/command_safety/windows_dangerous_commands.rs b/codex-rs/shell-command/src/command_safety/windows_dangerous_commands.rs index 9e5089d08d..8bcaf76266 100644 --- a/codex-rs/shell-command/src/command_safety/windows_dangerous_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_dangerous_commands.rs @@ -315,9 +315,10 @@ fn looks_like_url(token: &str) -> bool { Lazy::new(|| Regex::new(r#"^[ "'\(\s]*([^\s"'\);]+)[\s;\)]*$"#).ok()); // If the token embeds a URL alongside other text (e.g., Start-Process('https://...')) // as a single shlex token, grab the substring starting at the first URL prefix. - let urlish = token + let lowercase_token = token.to_ascii_lowercase(); + let urlish = lowercase_token .find("https://") - .or_else(|| token.find("http://")) + .or_else(|| lowercase_token.find("http://")) .map(|idx| &token[idx..]) .unwrap_or(token); @@ -434,6 +435,19 @@ mod tests { ]))); } + #[test] + fn powershell_start_process_mixed_case_urls_are_dangerous() { + for script in [ + "Start-Process('HTTP://example.com');", + "Start-Process('hTtPs://example.com');", + ] { + assert!( + is_dangerous_command_windows(&vec_str(&["powershell", "-Command", script])), + "{script}" + ); + } + } + #[test] fn powershell_start_process_local_is_not_flagged() { assert!(!is_dangerous_command_windows(&vec_str(&[ From 2589f7a52df8f65905d47e603f65ec458464d45e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 18:02:08 -0400 Subject: [PATCH 11/53] Handle completion separators and popup dismissal (#31191) ## Summary Autocomplete completion previously inserted a new space even when a separator was already present, which could leave redundant whitespace around neighboring text. Popup dismissal also tracked only the query string, so dismissing one token could suppress a different occurrence with the same text. This gives completions one horizontal-separator policy across files, images, skills, and mentions. Existing separators are reused when possible, ordinary suffix text remains separated, and line breaks stay intact. Dismissal now identifies the complete whitespace-delimited token occurrence, so offset-only edits preserve dismissal while later identical tokens remain independent. Newly completed values beginning with `$` or `@` can also remain closed when the next PR's affinity rule recognizes the token to the left of the cursor. ## Examples The examples below use `|` to represent the cursor. ### Reuse existing separators Starting with an existing two-space gap: ```text @ma| next ``` After accepting `src/main.rs` and typing `foo`, completion previously added a third separator and left both original spaces before `next`: ```text src/main.rs foo| next ``` After this PR, completion reuses the first existing separator as the insertion point and preserves the second between the new text and `next`: ```text src/main.rs foo| next ``` ### Keep a completed sigil-prefixed value closed Starting before a line break: ```text @ma| next ``` and accepting a path whose result is itself prefixed with `@` produces: ```text @scope/main.rs | next ``` Without this PR's completion dismissal, the affinity rule in #30463 rediscovers the completed token to the left of the cursor and reopens its popup: ```text @scope/main.rs | ^^^^^^^^^^^^^^^ popup reopens next ``` With this PR, the inserted occurrence is dismissed and the popup remains closed: ```text @scope/main.rs | ^^^^^^^^^^^^^^^ popup remains closed next ``` ### Do not dismiss an identical later occurrence Given two identical tokens: ```text @scope/main.rs| @scope/main.rs ``` after dismissing the first popup with Escape and moving to the second token, query-only dismissal previously suppressed the second popup too: ```text @scope/main.rs @scope/main.rs| ^^^^^^^^^^^^^ popup remains closed ``` After this PR, dismissal also matches the token's ordinal among complete tokens, so the second occurrence opens normally: ```text @scope/main.rs @scope/main.rs| ^^^^^^^^^^^^^ popup opens ``` ### Keep dismissal across offset-only edits After dismissing `@ma`, moving to its start, and pasting an email-like token: ```text email@ma.com @ma| ``` the `@ma` bytes embedded in `email@ma.com` do not count as another autocomplete token. The original `@ma` keeps its dismissal even though its byte range moved. ## Stack This is PR 2 of 3, stacked on #31190. It relies on the explicit replacement ranges introduced there and provides the completion lifecycle used by the targeting fix in #30463. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 366 ++++++++++++++++-- .../bottom_pane/chat_composer/popup_state.rs | 46 ++- 2 files changed, 374 insertions(+), 38 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5ba8d88ff5..76a5760658 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -16,6 +16,21 @@ //! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call //! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. //! +//! # Completion and Popup Dismissal +//! +//! Popup selection passes the detected token range directly to the insertion path. After replacing +//! that range, completion leaves the cursor after one horizontal separator. It advances across an +//! existing separator when no suffix would be joined; otherwise it inserts a space, preserving the +//! existing separator before a non-whitespace suffix. It also inserts a space rather than crossing +//! a line break. +//! +//! `Esc` records the active token as dismissed. A completed value that begins with `@` or `$` is +//! also re-dismissed before popup synchronization, because separator affinity can still identify +//! the completed token to the left of the cursor. Synchronization keeps the popup hidden only while +//! the query, complete token text, and ordinal among matching whitespace-delimited tokens remain +//! the same. This preserves dismissal across offset-only edits without suppressing a later +//! identical token. +//! //! # History Navigation (↑/↓) //! //! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges: @@ -225,6 +240,7 @@ use self::draft_state::DraftState; use self::footer_state::FooterState; use self::history_search::HistorySearchSession; use self::popup_state::ActivePopup; +use self::popup_state::DismissedToken; use self::popup_state::PopupState; use self::slash_input::SlashInput; use self::slash_input::SlashValidation; @@ -1802,8 +1818,16 @@ impl ChatComposer { KeyEvent { code: KeyCode::Esc, .. } => { - if let Some(tok) = Self::current_at_token(&self.draft.textarea) { - self.popups.dismissed_file_token = Some(tok); + if let Some((range, query)) = Self::current_prefixed_token_range( + &self.draft.textarea, + '@', + /*allow_empty*/ false, + ) { + self.popups.dismissed_file_token = Some(DismissedToken::new( + self.draft.textarea.text(), + range, + query, + )); } self.popups.active = ActivePopup::None; (InputResult::None, true) @@ -1879,8 +1903,12 @@ impl ChatComposer { KeyEvent { code: KeyCode::Esc, .. } => { - if let Some(tok) = self.current_mention_token() { - self.popups.dismissed_mention_token = Some(tok); + if let Some((range, query)) = self.current_mention_token_range() { + self.popups.dismissed_mention_token = Some(DismissedToken::new( + self.draft.textarea.text(), + range, + query, + )); } self.popups.active = ActivePopup::None; (InputResult::None, true) @@ -1983,8 +2011,12 @@ impl ChatComposer { KeyEvent { code: KeyCode::Esc, .. } => { - if let Some(tok) = self.current_mentions_v2_token() { - self.popups.dismissed_mention_token = Some(tok); + if let Some((range, query)) = self.current_mentions_v2_token_range() { + self.popups.dismissed_mention_token = Some(DismissedToken::new( + self.draft.textarea.text(), + range, + query, + )); } self.popups.active = ActivePopup::None; (InputResult::None, true) @@ -2044,6 +2076,86 @@ impl ChatComposer { || lower.ends_with(".webp") } + /// Leaves the cursor after one horizontal separator following a completion. + /// + /// Another separator is preserved before any non-whitespace suffix so subsequent typing does + /// not merge into it. Line breaks are never reused as separators; a space is inserted before + /// them so subsequent typing stays on the completed token's line. + fn advance_past_completion_separator(&mut self) { + let cursor = self.draft.textarea.cursor(); + let existing_separator_len = self.draft.textarea.text()[cursor..] + .chars() + .next() + .filter(|c| { + c.is_whitespace() + && !matches!( + *c, + '\n' | '\r' + | '\u{000B}' + | '\u{000C}' + | '\u{0085}' + | '\u{2028}' + | '\u{2029}' + ) + }) + .map(char::len_utf8); + if let Some(separator_len) = existing_separator_len { + let after_separator = cursor + separator_len; + let separator_precedes_suffix = self.draft.textarea.text()[after_separator..] + .chars() + .next() + .is_some_and(|c| !c.is_whitespace()); + if separator_precedes_suffix { + self.draft.textarea.insert_str(" "); + } else { + self.draft.textarea.set_cursor(after_separator); + } + } else { + self.draft.textarea.insert_str(" "); + } + } + + /// Dismisses popup synchronization only for the exact token occurrence just inserted. + /// + /// Matching both range and text prevents an identical token later in the draft from inheriting + /// the completed token's dismissal state. + fn dismiss_completed_prefixed_token( + &mut self, + prefix: char, + inserted_range: Range, + inserted_text: &str, + ) { + let Some(completed_token) = inserted_text.strip_prefix(prefix) else { + return; + }; + // Completion leaves the cursor on separator whitespace, where normal token affinity would + // otherwise immediately reopen the popup for sigil-prefixed inserted text. + let Some((current_range, current_token)) = Self::current_prefixed_token_range( + &self.draft.textarea, + prefix, + /*allow_empty*/ true, + ) else { + return; + }; + if current_range != inserted_range || current_token != completed_token { + return; + } + + if prefix == '@' && !self.mentions_v2_enabled { + self.popups.dismissed_file_token = Some(DismissedToken::new( + self.draft.textarea.text(), + current_range, + current_token, + )); + } else { + self.popups.dismissed_mention_token = Some(DismissedToken::new( + self.draft.textarea.text(), + current_range, + current_token, + )); + } + } + fn insert_selected_file_path(&mut self, token_range: Range, selected_path: &str) { if Self::is_image_path(selected_path) { let path_buf = PathBuf::from(selected_path); @@ -2054,7 +2166,7 @@ impl ChatComposer { self.draft.textarea.replace_range(token_range, ""); self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); - self.draft.textarea.insert_str(" "); + self.advance_past_completion_separator(); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); @@ -2395,10 +2507,6 @@ impl ChatComposer { Self::current_prefixed_token_range(&self.draft.textarea, '$', /*allow_empty*/ true) } - fn current_mention_token(&self) -> Option { - self.current_mention_token_range().map(|(_, token)| token) - } - /// Replace the active `@token` (the one under the cursor) with `path`. fn insert_selected_path(&mut self, token_range: Range, path: &str) { // If the path contains whitespace, wrap it in double quotes so the @@ -2414,11 +2522,11 @@ impl ChatComposer { // Replace just the active `@token` so unrelated text elements, such as // large-paste placeholders, remain atomic and can still expand on submit. let start_idx = token_range.start; - self.draft - .textarea - .replace_range(token_range, &format!("{inserted} ")); - let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.draft.textarea.set_cursor(new_cursor); + self.draft.textarea.replace_range(token_range, &inserted); + let inserted_range = start_idx..start_idx.saturating_add(inserted.len()); + self.draft.textarea.set_cursor(inserted_range.end); + self.advance_past_completion_separator(); + self.dismiss_completed_prefixed_token('@', inserted_range, &inserted); } fn insert_selected_mention( @@ -2432,6 +2540,7 @@ impl ChatComposer { self.draft.textarea.replace_range(token_range, ""); self.draft.textarea.set_cursor(start_idx); let id = self.draft.textarea.insert_element(insert_text); + let inserted_range = start_idx..start_idx.saturating_add(insert_text.len()); if let (Some(path), Some((sigil, mention))) = (path, Self::mention_token_from_insert_text(insert_text)) @@ -2446,11 +2555,12 @@ impl ChatComposer { ); } - self.draft.textarea.insert_str(" "); - let new_cursor = start_idx - .saturating_add(insert_text.len()) - .saturating_add(1); - self.draft.textarea.set_cursor(new_cursor); + self.advance_past_completion_separator(); + if let Some(sigil) = insert_text.chars().next() + && matches!(sigil, '$' | '@') + { + self.dismiss_completed_prefixed_token(sigil, inserted_range, insert_text); + } } fn mention_token_from_insert_text(insert_text: &str) -> Option<(char, String)> { @@ -3450,11 +3560,11 @@ impl ChatComposer { self.popups.active = ActivePopup::None; return; } - let mentions_v2_token = self.current_mentions_v2_token(); + let mentions_v2_token = self.current_mentions_v2_token_range(); let file_token = if self.mentions_v2_enabled { None } else { - self.current_editable_at_token() + self.current_editable_at_token_range_with_options(/*allow_empty*/ false) }; let browsing_history = self .history @@ -3470,7 +3580,7 @@ impl ChatComposer { self.popups.active = ActivePopup::None; return; } - let mention_token = self.current_mention_token(); + let mention_token = self.current_mention_token_range(); let allow_command_popup = self.slash_commands_enabled() && !self.draft.is_bash_mode @@ -3490,24 +3600,24 @@ impl ChatComposer { return; } - if let Some(token) = mentions_v2_token { - self.sync_mentions_v2_popup(token); + if let Some((range, token)) = mentions_v2_token { + self.sync_mentions_v2_popup(range, token); return; } - if let Some(token) = mention_token { + if let Some((range, token)) = mention_token { if self.popups.current_file_query.is_some() { self.app_event_tx .send(AppEvent::StartFileSearch(String::new())); self.popups.current_file_query = None; } - self.sync_mention_popup(token); + self.sync_mention_popup(range, token); return; } self.popups.dismissed_mention_token = None; - if let Some(token) = file_token { - self.sync_file_search_popup(token); + if let Some((range, token)) = file_token { + self.sync_file_search_popup(range, token); return; } @@ -3581,8 +3691,14 @@ impl ChatComposer { } /// Synchronize the legacy file-search popup with the current `@` token. - fn sync_file_search_popup(&mut self, query: String) { - if self.popups.dismissed_file_token.as_ref() == Some(&query) { + fn sync_file_search_popup(&mut self, range: Range, query: String) { + let text = self.draft.textarea.text(); + if self + .popups + .dismissed_file_token + .as_ref() + .is_some_and(|dismissed| dismissed.matches(text, &range, &query)) + { return; } @@ -3621,8 +3737,14 @@ impl ChatComposer { self.popups.dismissed_file_token = None; } - fn sync_mention_popup(&mut self, query: String) { - if self.popups.dismissed_mention_token.as_ref() == Some(&query) { + fn sync_mention_popup(&mut self, range: Range, query: String) { + let text = self.draft.textarea.text(); + if self + .popups + .dismissed_mention_token + .as_ref() + .is_some_and(|dismissed| dismissed.matches(text, &range, &query)) + { return; } @@ -3645,8 +3767,14 @@ impl ChatComposer { } } - fn sync_mentions_v2_popup(&mut self, query: String) { - if self.popups.dismissed_mention_token.as_ref() == Some(&query) { + fn sync_mentions_v2_popup(&mut self, range: Range, query: String) { + let text = self.draft.textarea.text(); + if self + .popups + .dismissed_mention_token + .as_ref() + .is_some_and(|dismissed| dismissed.matches(text, &range, &query)) + { return; } @@ -4419,8 +4547,24 @@ mod tests { use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::textarea::TextArea; use codex_protocol::models::local_image_label_text; + use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; + fn new_test_composer() -> (ChatComposer, UnboundedReceiver) { + let (tx, rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + ( + ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ), + rx, + ) + } + #[test] fn footer_hint_row_is_separated_from_composer() { let (tx, _rx) = unbounded_channel::(); @@ -8972,6 +9116,156 @@ mod tests { } } + fn complete_file( + composer: &mut ChatComposer, + text: &str, + cursor: usize, + query: &str, + selected_path: PathBuf, + ) { + composer.set_text_content(text.to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(cursor); + composer.sync_popups(); + let root = selected_path + .parent() + .filter(|_| selected_path.is_absolute()) + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("/tmp")); + composer.on_file_search_result( + query.to_string(), + vec![FileMatch { + score: 1, + path: selected_path, + match_type: codex_file_search::MatchType::File, + root, + indices: None, + }], + ); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + } + + #[test] + fn file_completion_preserves_separator_before_existing_suffix() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma next", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("src/main.rs"), + ); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), "src/main.rs foo next"); + } + + #[test] + fn file_completion_preserves_separator_before_sigiled_suffix() { + for suffix in ["@next", "$next"] { + let (mut composer, _rx) = new_test_composer(); + composer.set_text_content(format!("@ma {suffix}"), Vec::new(), Vec::new()); + composer.insert_selected_path(0.."@ma".len(), "src/main.rs"); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), format!("src/main.rs foo {suffix}")); + } + } + + #[test] + fn file_completion_inserts_separator_before_line_break() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma\nnext", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("src/main.rs"), + ); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), "src/main.rs foo\nnext"); + } + + #[test] + fn file_completion_for_sigil_path_does_not_reopen_popup() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma\nnext", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("@scope/main.rs"), + ); + + assert_eq!(composer.current_text(), "@scope/main.rs \nnext"); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, "@scope/main.rs \nnext"), + _ => panic!("expected completed path to submit"), + } + } + + #[test] + fn file_completion_does_not_dismiss_identical_next_token() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma @scope/main.rs", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("@scope/main.rs"), + ); + composer.draft.textarea.set_cursor("@scope/main.rs ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + } + + #[test] + fn dismissed_file_popup_tracks_token_across_leading_whitespace_edits() { + let (mut composer, _rx) = new_test_composer(); + composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@ma".len()); + composer.sync_popups(); + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.insert_str(" "); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + for _ in 0..3 { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + } + + #[test] + fn dismissed_file_popup_ignores_token_substrings_in_leading_paste() { + let (mut composer, _rx) = new_test_composer(); + composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@ma".len()); + composer.sync_popups(); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.handle_paste("email@ma.com ".to_string()); + + assert_eq!(composer.current_text(), "email@ma.com @ma"); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + /// Behavior: multiple paste operations can coexist; placeholders should be expanded to their /// original content on submission. #[test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs index fbc06303e5..71e17cc4fa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs @@ -5,13 +5,55 @@ use crate::bottom_pane::command_popup::CommandPopup; use crate::bottom_pane::file_search_popup::FileSearchPopup; use crate::bottom_pane::mentions_v2::MentionV2Popup; use crate::bottom_pane::skill_popup::SkillPopup; +use std::ops::Range; + +/// One token occurrence whose autocomplete popup should remain hidden. +pub(super) struct DismissedToken { + /// Popup query text for the token, excluding its leading sigil. + query: String, + /// Exact token text, including its sigil, captured when the popup was dismissed. + token: String, + /// Zero-based ordinal among identical token strings in the draft at dismissal time. + occurrence: usize, +} + +impl DismissedToken { + /// Captures the stable identity of the token at `range`. + pub(super) fn new(text: &str, range: Range, query: String) -> Self { + let token = text[range.clone()].to_string(); + let occurrence = complete_token_occurrences_before(text, &token, range.start); + Self { + query, + token, + occurrence, + } + } + + /// Returns whether `range` identifies the same token occurrence in the current draft. + /// + /// Byte offsets may shift under offset-only edits, while the token text and its ordinal keep + /// later identical occurrences distinct. + pub(super) fn matches(&self, text: &str, range: &Range, query: &str) -> bool { + if self.query != query || text.get(range.clone()) != Some(self.token.as_str()) { + return false; + } + complete_token_occurrences_before(text, &self.token, range.start) == self.occurrence + } +} + +fn complete_token_occurrences_before(text: &str, token: &str, before: usize) -> usize { + text[..before] + .split_whitespace() + .filter(|candidate| *candidate == token) + .count() +} #[derive(Default)] pub(super) struct PopupState { pub(super) active: ActivePopup, - pub(super) dismissed_file_token: Option, + pub(super) dismissed_file_token: Option, pub(super) current_file_query: Option, - pub(super) dismissed_mention_token: Option, + pub(super) dismissed_mention_token: Option, } impl PopupState { From 922249310f871f972f388df2706dca351ae9c6e2 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 15:20:48 -0700 Subject: [PATCH 12/53] test: add TestAppServer builder (#31425) ## Why Test callers need one composable way to create app-server fixtures instead of a growing family of overlapping constructor implementations. ## What - add a feature-complete TestAppServer::builder() - make the default builder own a temporary CODEX_HOME and select the automatic test environment - expose builder knobs for no automatic environment, explicit CODEX_HOME, program, arguments, plugin startup tasks, environment overrides, managed config, and JSON logging - keep the existing public constructor surface, but route every constructor through the builder so the new path is exercised immediately - remove the redundant private constructor ladders; caller migration and public constructor removal live in the optional cleanup stack ## Validation - just test -p codex-app-server (940/941 before updating the expected builder error wording) - just test -p codex-app-server auto_env_rejects_explicit_environment_config - just fix -p codex-app-server - just fmt ## Follow-up stacks Cleanup, optional for the benchmark work: 1. [#31451 test: migrate TestAppServer callers to builder](https://github.com/openai/codex/pull/31451) 2. [#31452 test: remove TestAppServer constructors](https://github.com/openai/codex/pull/31452) Benchmark infrastructure: 1. [#31427 test: add delayed exec-server transport](https://github.com/openai/codex/pull/31427) 2. [#31295 bench: add cold skill load macrobenchmark](https://github.com/openai/codex/pull/31295) 3. [#31428 bench: add e2e benchmark entrypoints](https://github.com/openai/codex/pull/31428) 4. [#31429 ci: smoke Bazel e2e benchmarks](https://github.com/openai/codex/pull/31429) --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/tests/common/Cargo.toml | 1 + codex-rs/app-server/tests/common/lib.rs | 1 + .../tests/common/test_app_server.rs | 312 +++++++++++++----- .../app-server/tests/suite/v2/auto_env.rs | 2 +- 5 files changed, 241 insertions(+), 76 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0d4d3c33bf..f6879c58e6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -412,6 +412,7 @@ dependencies = [ "serde", "serde_json", "shlex", + "tempfile", "tokio", "uuid", "wiremock", diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index a1537208bb..8123346dc6 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -27,6 +27,7 @@ codex-protocol = { workspace = true } codex-utils-cargo-bin = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index fbc674806a..f9dee08a5b 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -49,6 +49,7 @@ use serde::de::DeserializeOwned; pub use test_app_server::DEFAULT_CLIENT_NAME; pub use test_app_server::DISABLE_PLUGIN_STARTUP_TASKS_ARG; pub use test_app_server::TestAppServer; +pub use test_app_server::TestAppServerBuilder; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { let value = serde_json::to_value(response.result)?; 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 8605bc1a38..e080ab0261 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -1,5 +1,6 @@ use std::collections::VecDeque; use std::path::Path; +use std::path::PathBuf; use std::process::ExitStatus; use std::process::Stdio; use std::sync::atomic::AtomicI64; @@ -122,6 +123,7 @@ use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; use core_test_support::test_codex::TestEnv; use core_test_support::test_codex::test_env; +use tempfile::TempDir; use tokio::process::Command; use crate::json_logging::JsonLogCapture; @@ -138,6 +140,8 @@ pub struct TestAppServer { pending_messages: VecDeque, auto_env: Option, json_logs: JsonLogCapture, + codex_home: PathBuf, + _owned_codex_home: Option, } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; @@ -145,12 +149,28 @@ pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tas const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl TestAppServer { + /// Starts building a server with a temporary CODEX_HOME and the standard + /// automatic test environment. + pub fn builder() -> TestAppServerBuilder { + TestAppServerBuilder { + codex_home: None, + environment: TestAppServerEnvironment::Auto, + program: None, + env_overrides: Vec::new(), + args: vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG.to_string()], + } + } + pub async fn wait_for_exit(&mut self) -> std::io::Result { self.process.wait().await } pub async fn new(codex_home: &Path) -> anyhow::Result { - Self::new_with_env_and_args(codex_home, &[], &[DISABLE_PLUGIN_STARTUP_TASKS_ARG]).await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await } /// Starts an app server with the standard test environment and retains it @@ -164,7 +184,7 @@ impl TestAppServer { /// URL-based configuration, this helper rejects a `codex_home` containing /// that file. pub async fn new_with_auto_env(codex_home: &Path) -> anyhow::Result { - Self::new_with_auto_env_and_env(codex_home, &[]).await + Self::builder().with_codex_home(codex_home).build().await } /// Starts an auto-environment app server that emits JSON logs. @@ -174,47 +194,11 @@ impl TestAppServer { codex_home: &Path, rust_log: impl Into, ) -> anyhow::Result { - let rust_log = rust_log.into(); - Self::new_with_auto_env_and_env( - codex_home, - &[ - ("LOG_FORMAT", Some("json")), - ("RUST_LOG", Some(rust_log.as_str())), - ], - ) - .await - } - - async fn new_with_auto_env_and_env( - codex_home: &Path, - extra_env_overrides: &[(&str, Option<&str>)], - ) -> anyhow::Result { - let environments_toml = codex_home.join("environments.toml"); - ensure!( - !environments_toml - .try_exists() - .with_context(|| format!("check whether {} exists", environments_toml.display()))?, - "new_with_auto_env cannot be used when {} exists", - environments_toml.display() - ); - - let auto_env = test_env().await?; - // Noise registry configuration takes precedence over the URL-based - // provider, so clear inherited values to keep the selection hermetic. - let mut env_overrides = vec![ - ( - CODEX_EXEC_SERVER_URL_ENV_VAR, - auto_env.environment().exec_server_url(), - ), - (CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR, None), - (CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR, None), - (CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, None), - (CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR, None), - ]; - env_overrides.extend_from_slice(extra_env_overrides); - let mut app_server = Self::new_with_env(codex_home, &env_overrides).await?; - app_server.auto_env = Some(auto_env); - Ok(app_server) + Self::builder() + .with_codex_home(codex_home) + .with_json_logging(rust_log) + .build() + .await } /// Returns the automatically selected test environment retained by this server. @@ -238,6 +222,11 @@ impl TestAppServer { }) } + /// Returns the effective CODEX_HOME used by the child app-server. + pub fn codex_home(&self) -> &Path { + &self.codex_home + } + /// Waits for a JSON stderr event whose structured `event.name` field matches. pub async fn wait_for_json_log_event( &self, @@ -261,33 +250,56 @@ impl TestAppServer { } pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { - Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await } pub async fn new_without_managed_config_with_env( codex_home: &Path, env_overrides: &[(&str, Option<&str>)], ) -> anyhow::Result { - let mut all_env_overrides = vec![(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]; - all_env_overrides.extend_from_slice(env_overrides); - Self::new_with_env(codex_home, &all_env_overrides).await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .without_managed_config() + .with_env_overrides(env_overrides) + .build() + .await } pub async fn new_with_plugin_startup_tasks(codex_home: &Path) -> anyhow::Result { - Self::new_with_env_and_args(codex_home, &[], &[]).await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_plugin_startup_tasks() + .build() + .await } pub async fn new_with_env_and_plugin_startup_tasks( codex_home: &Path, env_overrides: &[(&str, Option<&str>)], ) -> anyhow::Result { - Self::new_with_env_and_args(codex_home, env_overrides, &[]).await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_plugin_startup_tasks() + .with_env_overrides(env_overrides) + .build() + .await } pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { - let mut all_args = vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG]; - all_args.extend_from_slice(args); - Self::new_with_env_and_args(codex_home, &[], &all_args).await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_args(args) + .build() + .await } /// Creates a new MCP process, allowing tests to override or remove @@ -299,12 +311,12 @@ impl TestAppServer { codex_home: &Path, env_overrides: &[(&str, Option<&str>)], ) -> anyhow::Result { - Self::new_with_env_and_args( - codex_home, - env_overrides, - &[DISABLE_PLUGIN_STARTUP_TASKS_ARG], - ) - .await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_env_overrides(env_overrides) + .build() + .await } pub async fn new_with_program_and_env( @@ -312,23 +324,13 @@ impl TestAppServer { program: &Path, env_overrides: &[(&str, Option<&str>)], ) -> anyhow::Result { - Self::new_with_program_env_and_args( - codex_home, - program, - env_overrides, - &[DISABLE_PLUGIN_STARTUP_TASKS_ARG], - ) - .await - } - - async fn new_with_env_and_args( - codex_home: &Path, - env_overrides: &[(&str, Option<&str>)], - args: &[&str], - ) -> anyhow::Result { - let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") - .context("should find binary for codex-app-server")?; - Self::new_with_program_env_and_args(codex_home, &program, env_overrides, args).await + Self::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_program(program) + .with_env_overrides(env_overrides) + .build() + .await } async fn new_with_program_env_and_args( @@ -399,6 +401,8 @@ impl TestAppServer { pending_messages: VecDeque::new(), auto_env: None, json_logs, + codex_home: codex_home.to_path_buf(), + _owned_codex_home: None, }) } @@ -1800,6 +1804,164 @@ impl TestAppServer { } } +/// Builder for TestAppServer. +pub struct TestAppServerBuilder { + codex_home: Option, + environment: TestAppServerEnvironment, + program: Option, + env_overrides: Vec<(String, Option)>, + args: Vec, +} + +enum TestAppServerEnvironment { + Auto, + None, +} + +impl TestAppServerBuilder { + /// Uses this existing CODEX_HOME instead of a temporary one. + pub fn with_codex_home(mut self, codex_home: &Path) -> Self { + self.codex_home = Some(codex_home.to_path_buf()); + self + } + + /// Starts app-server without the standard automatic test environment. + pub fn without_auto_env(mut self) -> Self { + self.environment = TestAppServerEnvironment::None; + self + } + + /// Uses this app-server binary instead of the standard test binary. + pub fn with_program(mut self, program: &Path) -> Self { + self.program = Some(program.to_path_buf()); + self + } + + /// Adds command-line arguments after the default test arguments. + pub fn with_args(mut self, args: &[&str]) -> Self { + self.args + .extend(args.iter().map(|argument| (*argument).to_string())); + self + } + + /// Enables startup tasks that the default test arguments disable. + pub fn with_plugin_startup_tasks(mut self) -> Self { + self.args + .retain(|argument| argument != DISABLE_PLUGIN_STARTUP_TASKS_ARG); + self + } + + /// Adds child-process environment overrides. + /// + /// Some values set variables and None values remove inherited variables. + pub fn with_env_overrides(mut self, env_overrides: &[(&str, Option<&str>)]) -> Self { + self.env_overrides + .extend(env_overrides.iter().map(|(key, value)| { + ( + (*key).to_string(), + value.map(std::string::ToString::to_string), + ) + })); + self + } + + /// Prevents the child from loading managed configuration. + pub fn without_managed_config(self) -> Self { + self.with_env_overrides(&[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]) + } + + /// Configures the child to emit JSON logs at the requested Rust log level. + pub fn with_json_logging(self, rust_log: impl Into) -> Self { + let rust_log = rust_log.into(); + let mut builder = self.with_env_overrides(&[("LOG_FORMAT", Some("json"))]); + builder + .env_overrides + .push(("RUST_LOG".to_string(), Some(rust_log))); + builder + } + + /// Builds a server with a temporary CODEX_HOME and automatic environment + /// by default. + pub async fn build(self) -> anyhow::Result { + let Self { + codex_home, + environment, + program, + mut env_overrides, + args, + } = self; + let (codex_home, owned_codex_home) = match codex_home { + Some(codex_home) => (codex_home, None), + None => { + let owned_codex_home = TempDir::new()?; + ( + owned_codex_home.path().to_path_buf(), + Some(owned_codex_home), + ) + } + }; + let auto_env = match environment { + TestAppServerEnvironment::Auto => { + let environments_toml = codex_home.join("environments.toml"); + ensure!( + !environments_toml.try_exists().with_context(|| format!( + "check whether {} exists", + environments_toml.display() + ))?, + "automatic environment cannot be used when {} exists", + environments_toml.display() + ); + let auto_env = test_env().await?; + // Noise registry configuration takes precedence over the URL-based + // provider, so clear inherited values to keep the selection hermetic. + let mut auto_env_overrides = vec![ + ( + CODEX_EXEC_SERVER_URL_ENV_VAR.to_string(), + auto_env.environment().exec_server_url().map(str::to_string), + ), + ( + CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR.to_string(), + None, + ), + ( + CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR.to_string(), + None, + ), + (CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(), None), + ( + CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR.to_string(), + None, + ), + ]; + auto_env_overrides.append(&mut env_overrides); + env_overrides = auto_env_overrides; + Some(auto_env) + } + TestAppServerEnvironment::None => None, + }; + let program = match program { + Some(program) => program, + None => codex_utils_cargo_bin::cargo_bin("codex-app-server") + .context("should find binary for codex-app-server")?, + }; + let env_overrides = env_overrides + .iter() + .map(|(key, value)| (key.as_str(), value.as_deref())) + .collect::>(); + let args = args.iter().map(String::as_str).collect::>(); + let mut app_server = TestAppServer::new_with_program_env_and_args( + &codex_home, + &program, + &env_overrides, + &args, + ) + .await?; + app_server.auto_env = auto_env; + app_server._owned_codex_home = owned_codex_home; + Ok(app_server) + } +} + impl Drop for TestAppServer { fn drop(&mut self) { // These tests spawn a `codex-app-server` child process. diff --git a/codex-rs/app-server/tests/suite/v2/auto_env.rs b/codex-rs/app-server/tests/suite/v2/auto_env.rs index d4c88f48fe..56190d0f16 100644 --- a/codex-rs/app-server/tests/suite/v2/auto_env.rs +++ b/codex-rs/app-server/tests/suite/v2/auto_env.rs @@ -117,7 +117,7 @@ async fn auto_env_rejects_explicit_environment_config() -> Result<()> { assert_eq!( err.to_string(), format!( - "new_with_auto_env cannot be used when {} exists", + "automatic environment cannot be used when {} exists", codex_home.path().join("environments.toml").display() ) ); From 8139255b46ca91329bae1f545ece4f2f6265bd92 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 7 Jul 2026 15:37:50 -0700 Subject: [PATCH 13/53] http-client: expose WebSocket proxy prerequisites (#31342) ## Why #31335 lets HTTP callers obtain proxy-aware clients from `HttpClientFactory`, but a non-HTTP transport such as WebSockets also needs two pieces of policy owned by `codex-http-client`: a concrete route decision for its destination and the same custom-CA-aware rustls trust configuration used by HTTPS. Keeping these prerequisites in the shared abstraction means the dependent Responses WebSocket change (#31441) cannot independently reinterpret `features.respect_system_proxy`, PAC results, or enterprise CA settings. ## What changed - Add a redaction-safe `OutboundProxyRoute` with explicit transport-default, direct, and concrete-proxy outcomes. - Add `HttpClientFactory::resolve_proxy_route()` so transports can resolve a destination through the already-selected outbound proxy policy. - Resolve `ws://` and `wss://` URLs through their HTTP equivalents so system and PAC rules apply consistently. - Add an always-returned rustls config builder that starts from native roots and layers in any configured Codex custom CA bundle. The existing optional builder remains available to callers that can delegate the default configuration to their transport. - Continue redacting proxy URLs from `Debug` output because they may contain credentials. ## Review guide 1. `http-client/src/outbound_proxy.rs` defines the transport-neutral route result and WebSocket URL normalization. 2. `http-client/src/custom_ca.rs` factors the native-root/custom-CA construction so callers that perform TLS themselves can always obtain a config. 3. `http-client/src/outbound_proxy_tests.rs` verifies WebSocket normalization and legacy transport-default behavior. ## Test plan - `just test -p codex-http-client outbound_proxy` - `just test -p codex-http-client custom_ca` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/31342). * #31431 * #31363 * #31362 * #31361 * #31442 * #31441 * __->__ #31342 --- codex-rs/http-client/src/custom_ca.rs | 61 ++++++++++----- codex-rs/http-client/src/lib.rs | 2 + codex-rs/http-client/src/outbound_proxy.rs | 74 ++++++++++++++++++- .../http-client/src/outbound_proxy_tests.rs | 35 +++++++++ 4 files changed, 152 insertions(+), 20 deletions(-) diff --git a/codex-rs/http-client/src/custom_ca.rs b/codex-rs/http-client/src/custom_ca.rs index 4cfae8f23d..5a2e51c320 100644 --- a/codex-rs/http-client/src/custom_ca.rs +++ b/codex-rs/http-client/src/custom_ca.rs @@ -198,6 +198,16 @@ pub fn maybe_build_rustls_client_config_with_custom_ca() maybe_build_rustls_client_config_with_env(&ProcessEnv) } +/// Builds a rustls client config using native roots and any configured Codex custom CA bundle. +/// +/// Unlike [`maybe_build_rustls_client_config_with_custom_ca`], this always returns a config. Use +/// this when the caller must perform TLS itself instead of delegating default configuration to a +/// transport library. +pub fn build_rustls_client_config_with_custom_ca() +-> Result, BuildCustomCaTransportError> { + build_rustls_client_config_with_env(&ProcessEnv) +} + /// Builds a reqwest client for spawned subprocess tests that exercise CA behavior. /// /// This is the test-only client-construction path used by the subprocess coverage in `tests/`. @@ -219,6 +229,19 @@ fn maybe_build_rustls_client_config_with_env( return Ok(None); }; + build_rustls_client_config(Some(&bundle)).map(Some) +} + +fn build_rustls_client_config_with_env( + env_source: &dyn EnvSource, +) -> Result, BuildCustomCaTransportError> { + let bundle = env_source.configured_ca_bundle(); + build_rustls_client_config(bundle.as_ref()) +} + +fn build_rustls_client_config( + bundle: Option<&ConfiguredCaBundle>, +) -> Result, BuildCustomCaTransportError> { ensure_rustls_crypto_provider(); // Start from the platform roots so websocket callers keep the same baseline trust behavior @@ -235,30 +258,32 @@ fn maybe_build_rustls_client_config_with_env( } let _ = root_store.add_parsable_certificates(certs); - let certificates = bundle.load_certificates()?; - for (idx, cert) in certificates.into_iter().enumerate() { - if let Err(source) = root_store.add(cert) { - warn!( - source_env = bundle.source_env, - ca_path = %bundle.path.display(), - certificate_index = idx + 1, - error = %source, - "failed to register CA certificate in rustls root store" - ); - return Err(BuildCustomCaTransportError::RegisterRustlsCertificate { - source_env: bundle.source_env, - path: bundle.path.clone(), - certificate_index: idx + 1, - source, - }); + if let Some(bundle) = bundle { + let certificates = bundle.load_certificates()?; + for (idx, cert) in certificates.into_iter().enumerate() { + if let Err(source) = root_store.add(cert) { + warn!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + certificate_index = idx + 1, + error = %source, + "failed to register CA certificate in rustls root store" + ); + return Err(BuildCustomCaTransportError::RegisterRustlsCertificate { + source_env: bundle.source_env, + path: bundle.path.clone(), + certificate_index: idx + 1, + source, + }); + } } } - Ok(Some(Arc::new( + Ok(Arc::new( ClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth(), - ))) + )) } /// Builds a reqwest client using an injected environment source and reqwest builder. diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs index 07e579ecb3..49fc4225c3 100644 --- a/codex-rs/http-client/src/lib.rs +++ b/codex-rs/http-client/src/lib.rs @@ -18,6 +18,7 @@ pub use crate::custom_ca::BuildCustomCaTransportError; #[doc(hidden)] pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests; pub use crate::custom_ca::build_reqwest_client_with_custom_ca; +pub use crate::custom_ca::build_rustls_client_config_with_custom_ca; pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; pub use crate::default_client::HttpClient; pub use crate::default_client::RequestBuilder; @@ -27,6 +28,7 @@ pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; pub use crate::outbound_proxy::ClientRouteClass; pub use crate::outbound_proxy::HttpClientFactory; pub use crate::outbound_proxy::OutboundProxyPolicy; +pub use crate::outbound_proxy::OutboundProxyRoute; pub use crate::outbound_proxy::RouteFailureClass; pub use crate::request::EncodedJsonBody; pub use crate::request::PreparedRequestBody; diff --git a/codex-rs/http-client/src/outbound_proxy.rs b/codex-rs/http-client/src/outbound_proxy.rs index 5506f3d40b..71793025ed 100644 --- a/codex-rs/http-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -4,6 +4,7 @@ //! proxies are the fallback, and the final fallback is a direct connection. //! When disabled, callers retain the existing reqwest builder behavior. +use std::borrow::Cow; use std::collections::HashMap; use std::fmt; use std::io; @@ -96,6 +97,30 @@ pub enum OutboundProxyPolicy { RespectSystemProxy, } +/// Resolved proxy route for a concrete outbound destination. +/// +/// `TransportDefault` delegates environment-proxy handling to the underlying transport. Proxy +/// URLs are intentionally redacted from `Debug` output because they may contain credentials. +#[derive(Clone, PartialEq, Eq)] +pub enum OutboundProxyRoute { + /// Preserve the underlying transport's existing proxy behavior. + TransportDefault, + /// Connect directly and bypass transport-level proxy discovery. + Direct, + /// Connect through the selected proxy URL. + Proxy { url: String }, +} + +impl fmt::Debug for OutboundProxyRoute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TransportDefault => f.write_str("TransportDefault"), + Self::Direct => f.write_str("Direct"), + Self::Proxy { .. } => f.debug_struct("Proxy").field("url", &"").finish(), + } + } +} + /// Builds route-specific HTTP clients using one resolved outbound proxy policy. /// /// Construct this once from the effective application configuration and carry it with the @@ -119,6 +144,20 @@ impl HttpClientFactory { self.outbound_proxy_policy } + /// Resolves the proxy route for a concrete destination. + /// + /// WebSocket schemes are resolved through their HTTP equivalents so platform PAC and system + /// proxy APIs apply the same policy to `ws`/`wss` and `http`/`https` destinations. When system + /// resolution is unavailable, the transport retains responsibility for environment-proxy + /// fallback. + pub fn resolve_proxy_route(&self, request_url: &str) -> OutboundProxyRoute { + resolve_proxy_route( + request_url, + self.outbound_proxy_policy, + resolve_system_proxy, + ) + } + /// Builds a reqwest client for a concrete outbound route. pub fn build_reqwest_client( &self, @@ -135,6 +174,37 @@ impl HttpClientFactory { } } +fn resolve_proxy_route( + request_url: &str, + outbound_proxy_policy: OutboundProxyPolicy, + resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, +) -> OutboundProxyRoute { + if matches!(outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault) { + return OutboundProxyRoute::TransportDefault; + } + + let request_url = proxy_resolution_url(request_url); + let Some(origin) = RequestOrigin::parse(&request_url) else { + return OutboundProxyRoute::TransportDefault; + }; + + match resolve_system_proxy(&request_url, &origin) { + SystemProxyDecision::Direct => OutboundProxyRoute::Direct, + SystemProxyDecision::Proxy { url } => OutboundProxyRoute::Proxy { url }, + SystemProxyDecision::Unavailable { .. } => OutboundProxyRoute::TransportDefault, + } +} + +fn proxy_resolution_url(request_url: &str) -> Cow<'_, str> { + if let Some(suffix) = request_url.strip_prefix("wss://") { + Cow::Owned(format!("https://{suffix}")) + } else if let Some(suffix) = request_url.strip_prefix("ws://") { + Cow::Owned(format!("http://{suffix}")) + } else { + Cow::Borrowed(request_url) + } +} + /// Error while building a resolver-aware reqwest client. #[derive(Debug, Error)] pub enum BuildRouteAwareHttpClientError { @@ -259,8 +329,8 @@ impl RequestOrigin { let scheme = uri.scheme_str()?.to_ascii_lowercase(); let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase(); let port = uri.port_u16().or(match scheme.as_str() { - "http" => Some(80), - "https" => Some(443), + "http" | "ws" => Some(80), + "https" | "wss" => Some(443), _ => None, })?; Some(Self { scheme, host, port }) diff --git a/codex-rs/http-client/src/outbound_proxy_tests.rs b/codex-rs/http-client/src/outbound_proxy_tests.rs index 734d5483ec..3f8bd925c9 100644 --- a/codex-rs/http-client/src/outbound_proxy_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy_tests.rs @@ -9,6 +9,41 @@ struct MapEnv { values: HashMap, } +#[test] +fn websocket_route_uses_http_equivalent_for_system_resolution() { + let route = resolve_proxy_route( + "wss://api.openai.com/v1/responses", + OutboundProxyPolicy::RespectSystemProxy, + |request_url, origin| { + assert_eq!(request_url, "https://api.openai.com/v1/responses"); + assert_eq!(origin.scheme, "https"); + assert_eq!(origin.host, "api.openai.com"); + assert_eq!(origin.port, 443); + SystemProxyDecision::Proxy { + url: "http://proxy.example:8080".to_string(), + } + }, + ); + + assert_eq!( + route, + OutboundProxyRoute::Proxy { + url: "http://proxy.example:8080".to_string(), + } + ); +} + +#[test] +fn reqwest_default_route_preserves_transport_proxy_behavior() { + let route = resolve_proxy_route( + "wss://api.openai.com/v1/responses", + OutboundProxyPolicy::ReqwestDefault, + |_, _| panic!("default policy should not resolve system proxy settings"), + ); + + assert_eq!(route, OutboundProxyRoute::TransportDefault); +} + impl EnvSource for MapEnv { fn var(&self, key: &str) -> Option { self.values.get(key).cloned() From 58b66d39e490890437d161a70d5302130f5988cf Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 16:00:15 -0700 Subject: [PATCH 14/53] perf(skills): resolve plugin namespaces per root (#31348) ## Why Loading skills from a remote executor can add a lot to thread start time when there are many skills. Previous changes added some concurrency for the file reads themselves, but we're still bottlenecked on the initial root path discovery. Using the benchmark from [#31295](https://github.com/openai/codex/pull/31295), this change reduces the measured mean of loading 66 skills about 71%. Behavioral coverage lands separately in [#31369](https://github.com/openai/codex/pull/31369). ## What - resolve the scanned root's inherited namespace once - resolve discovered nested plugin roots once, retaining nearest-valid-ancestor behavior - pass an explicit resolved Plain / Plugin namespace into skill parsing instead of probing per skill - preserve explicitly provided plugin namespaces as the highest-priority source - reuse the same resolver for environment skills, deleting its duplicate root-probe and ancestor-selection path ## Validation - just test -p codex-core-skills namespace: 12 passed on both the parent and optimized branches --- codex-rs/core-skills/src/loader.rs | 42 +++--- .../core-skills/src/loader/environment.rs | 74 +++------- codex-rs/core-skills/src/loader/namespace.rs | 134 ++++++++++++++++++ 3 files changed, 173 insertions(+), 77 deletions(-) create mode 100644 codex-rs/core-skills/src/loader/namespace.rs diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index c9ace54204..e74eb01a0c 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -1,4 +1,5 @@ mod environment; +mod namespace; pub use environment::EnvironmentSkillLoadOutcome; pub use environment::EnvironmentSkillMetadata; @@ -27,9 +28,10 @@ use codex_utils_absolute_path::AbsolutePathBufGuard; use codex_utils_path_uri::PathUri; use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; use codex_utils_plugins::PluginSkillRoot; -use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; use futures::future::join_all; +use namespace::ResolvedSkillNamespace; +use namespace::SkillNamespaceResolver; use serde::Deserialize; use std::collections::HashSet; use std::collections::VecDeque; @@ -680,12 +682,27 @@ async fn load_skills_under_root( }; let SkillFileDiscovery { skill_files, + plugin_roots, + namespace_roots, warnings, - .. } = discover_skills_under_root(fs, &PathUri::from_abs_path(root), symlink_policy).await; for warning in warnings { error!("{warning}"); } + let root_uri = PathUri::from_abs_path(root); + let namespace_resolver = match plugin_namespace { + Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace), + None => { + SkillNamespaceResolver::discover( + fs, + &root_uri, + &skill_files, + plugin_roots, + namespace_roots, + ) + .await + } + }; for path_uri in skill_files { let path = match path_uri.to_abs_path() { Ok(path) => path, @@ -699,7 +716,7 @@ async fn load_skills_under_root( &path, scope, plugin_id, - plugin_namespace, + namespace_resolver.for_skill(&root_uri, &path_uri), plugin_root.as_ref(), ) .await @@ -719,7 +736,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, - plugin_namespace: Option<&str>, + namespace: &ResolvedSkillNamespace, plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let path_uri = PathUri::from_abs_path(path); @@ -732,7 +749,7 @@ async fn parse_skill_file( description, short_description, } = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(path))?; - let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await; + let name = namespace.qualify(&base_name); let LoadedSkillMetadata { interface, dependencies, @@ -818,21 +835,6 @@ fn default_skill_name(path: &AbsolutePathBuf) -> String { .unwrap_or_else(|| "skill".to_string()) } -async fn namespaced_skill_name( - fs: &dyn ExecutorFileSystem, - path: &AbsolutePathBuf, - base_name: &str, - plugin_namespace: Option<&str>, -) -> String { - if let Some(plugin_namespace) = plugin_namespace { - return format!("{plugin_namespace}:{base_name}"); - } - plugin_namespace_for_skill_path(fs, path) - .await - .map(|namespace| format!("{namespace}:{base_name}")) - .unwrap_or_else(|| base_name.to_string()) -} - async fn load_skill_metadata( fs: &dyn ExecutorFileSystem, skill_path: &AbsolutePathBuf, diff --git a/codex-rs/core-skills/src/loader/environment.rs b/codex-rs/core-skills/src/loader/environment.rs index 3d50bafe1f..da525d3362 100644 --- a/codex-rs/core-skills/src/loader/environment.rs +++ b/codex-rs/core-skills/src/loader/environment.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::collections::HashSet; use std::io; @@ -8,10 +7,7 @@ use codex_exec_server::WalkOptions; use codex_protocol::protocol::Product; use codex_utils_path_uri::PathUri; use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; -use codex_utils_plugins::plugin_namespace_for_root_uri; -use codex_utils_plugins::plugin_namespace_for_skill_uri; use futures::StreamExt; -use futures::future::join_all; use crate::model::SkillDependencies; use crate::model::SkillPolicy; @@ -24,6 +20,7 @@ use super::SKILLS_FILENAME; use super::SKILLS_METADATA_DIR; use super::SKILLS_METADATA_FILENAME; use super::SkillMetadataFile; +use super::namespace::SkillNamespaceResolver; use super::parse_skill_frontmatter_metadata_inner; use super::resolve_dependencies; use super::resolve_policy; @@ -247,45 +244,18 @@ pub async fn load_environment_skills_from_root( return outcome; } - let mut skill_ancestors = HashSet::new(); - for skill in &discovery.skills { - let mut ancestor = skill.path.parent(); - while let Some(path) = ancestor { - skill_ancestors.insert(path.clone()); - ancestor = path.parent(); - } - } - - let namespace_roots = discovery.namespace_roots; - let plugin_namespaces = async { - let namespace_lookups = join_all(namespace_roots.iter().map(|namespace_root| async { - ( - namespace_root.clone(), - plugin_namespace_for_skill_uri(file_system, namespace_root).await, - ) - })); - let plugin_lookups = join_all( - discovery - .plugin_roots - .iter() - .filter(|plugin_root| skill_ancestors.contains(*plugin_root)) - .filter(|plugin_root| !namespace_roots.contains(*plugin_root)) - .map(|plugin_root| async { - ( - plugin_root.clone(), - plugin_namespace_for_root_uri(file_system, plugin_root).await, - ) - }), - ); - let (namespace_lookups, plugin_lookups) = tokio::join!(namespace_lookups, plugin_lookups); - namespace_lookups - .into_iter() - .chain(plugin_lookups) - .filter_map(|(plugin_root, namespace)| { - namespace.map(|namespace| (plugin_root, namespace)) - }) - .collect::>() - }; + let skill_paths = discovery + .skills + .iter() + .map(|skill| skill.path.clone()) + .collect::>(); + let namespace_resolver = SkillNamespaceResolver::discover( + file_system, + root, + &skill_paths, + discovery.plugin_roots, + discovery.namespace_roots, + ); // Remote executors can multiplex these independent per-skill reads, so polling a bounded // number together allows the I/O for each skill and its metadata to happen concurrently. @@ -301,23 +271,13 @@ pub async fn load_environment_skills_from_root( }) .buffered(MAX_CONCURRENT_SKILL_LOADS) .collect::>(); - let (plugin_namespaces, skill_results) = tokio::join!(plugin_namespaces, skill_results); + let (namespace_resolver, skill_results) = tokio::join!(namespace_resolver, skill_results); for (path, result) in skill_results { let result = result.and_then(|skill| { - let mut ancestor = skill.path_to_skills_md.parent(); - let plugin_namespace = loop { - let Some(current) = ancestor else { - break None; - }; - if let Some(namespace) = plugin_namespaces.get(¤t) { - break Some(namespace.as_str()); - } - ancestor = current.parent(); - }; - let name = plugin_namespace - .map(|namespace| format!("{namespace}:{}", skill.base_name)) - .unwrap_or(skill.base_name); + let name = namespace_resolver + .for_skill(root, &skill.path_to_skills_md) + .qualify(&skill.base_name); validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name") .map_err(|err| err.to_string())?; diff --git a/codex-rs/core-skills/src/loader/namespace.rs b/codex-rs/core-skills/src/loader/namespace.rs new file mode 100644 index 0000000000..34d8e9b7c4 --- /dev/null +++ b/codex-rs/core-skills/src/loader/namespace.rs @@ -0,0 +1,134 @@ +use codex_exec_server::ExecutorFileSystem; +use codex_utils_path_uri::PathUri; +use codex_utils_plugins::plugin_namespace_for_root_uri; +use codex_utils_plugins::plugin_namespace_for_skill_uri; +use futures::future::join_all; +use std::collections::HashSet; + +/// Resolves the namespace prefix applied to skill names during one skills scan. +/// +/// A plugin namespace is the plugin name from the nearest valid plugin manifest +/// above a skill path. For example, a skill named `search` beneath a plugin named +/// `sample` is exposed as `sample:search`. +/// +/// Resolving the namespace separately for every `SKILL.md` repeats the same +/// ancestor manifest probes for sibling skills. This resolver resolves relevant +/// roots once per scan, then selects the nearest matching root for each skill. +/// +/// Namespace precedence is: +/// +/// 1. an explicitly provided plugin namespace; +/// 2. the deepest matching canonical symlink root or nested plugin root; +/// 3. the namespace inherited from the scanned skills root. +pub(crate) struct SkillNamespaceResolver { + inherited_namespace: ResolvedSkillNamespace, + nested_namespaces: Vec<(PathUri, ResolvedSkillNamespace)>, +} + +impl SkillNamespaceResolver { + /// Builds a resolver whose explicit plugin-owned namespace overrides discovery. + pub(crate) fn with_provided_namespace(namespace: &str) -> Self { + Self { + inherited_namespace: ResolvedSkillNamespace::Plugin(namespace.to_string()), + nested_namespaces: Vec::new(), + } + } + + pub(crate) async fn discover( + fs: &dyn ExecutorFileSystem, + root: &PathUri, + skill_paths: &[PathUri], + plugin_roots: HashSet, + namespace_roots: HashSet, + ) -> Self { + // Only probe plugin roots above loaded skills; unused siblings cannot affect names. + let mut skill_ancestors = HashSet::new(); + for skill_path in skill_paths { + let mut ancestor = skill_path.parent(); + while let Some(path) = ancestor { + skill_ancestors.insert(path.clone()); + ancestor = path.parent(); + } + } + let plugin_roots = plugin_roots + .into_iter() + .filter(|plugin_root| skill_ancestors.contains(plugin_root)) + .collect::>(); + + // Ordinary descendants fall back to the nearest valid manifest at or above the scan root. + let inherited_namespace = plugin_namespace_for_skill_uri(fs, root) + .await + .map(ResolvedSkillNamespace::Plugin) + .unwrap_or(ResolvedSkillNamespace::Plain); + // The scan root is already the fallback above if nothing else matches, exclude from the search. + let namespace_roots = namespace_roots + .into_iter() + .filter(|namespace_root| namespace_root != root) + .collect::>(); + let namespace_root_set = namespace_roots.iter().cloned().collect::>(); + // Keep independent probes concurrent for remote executor latency. + let namespace_lookups = join_all(namespace_roots.into_iter().map(|namespace_root| async { + let namespace = plugin_namespace_for_skill_uri(fs, &namespace_root) + .await + .map(ResolvedSkillNamespace::Plugin) + .unwrap_or(ResolvedSkillNamespace::Plain); + (namespace_root, namespace) + })); + // Invalid nested manifests are omitted, so the deepest remaining match wins. + let plugin_lookups = join_all( + plugin_roots + .into_iter() + .filter(|plugin_root| { + plugin_root != root && !namespace_root_set.contains(plugin_root) + }) + .map(|plugin_root| async move { + plugin_namespace_for_root_uri(fs, &plugin_root) + .await + .map(|namespace| (plugin_root, ResolvedSkillNamespace::Plugin(namespace))) + }), + ); + let (namespace_lookups, plugin_lookups) = tokio::join!(namespace_lookups, plugin_lookups); + let nested_namespaces = namespace_lookups + .into_iter() + .chain(plugin_lookups.into_iter().flatten()) + .collect(); + + Self { + inherited_namespace, + nested_namespaces, + } + } + + pub(crate) fn for_skill(&self, root: &PathUri, path: &PathUri) -> &ResolvedSkillNamespace { + // Ancestor symlink targets cannot override skills still owned by the scan root. + let path_is_under_root = path.starts_with(root); + // The deepest matching path prefix is the nearest applicable namespace. + self.nested_namespaces + .iter() + .filter(|(namespace_root, _)| { + path.starts_with(namespace_root) + && (!path_is_under_root || !root.starts_with(namespace_root)) + }) + .max_by_key(|(namespace_root, _)| namespace_root.ancestors().count()) + .map(|(_, namespace)| namespace) + .unwrap_or(&self.inherited_namespace) + } +} + +/// The completed namespace resolution for a skill root. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ResolvedSkillNamespace { + /// No plugin namespace applies to matching skills. + Plain, + /// Qualify matching skill names with this plugin namespace. + Plugin(String), +} + +impl ResolvedSkillNamespace { + pub(crate) fn qualify(&self, base_name: &str) -> String { + match self { + Self::Plain => base_name.to_string(), + Self::Plugin(namespace) => format!("{namespace}:{base_name}"), + } + } +} From 07d631875e38ade29e290e3036daf2342984b65d Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Tue, 7 Jul 2026 16:17:12 -0700 Subject: [PATCH 15/53] Use canonical indexed web access field (#31289) ## Summary - Rename the hosted web-search wire field to the canonical `indexed_web_access` spelling. - Preserve existing indexed-search behavior. ## Rollout Merge and release only after server support for `indexed_web_access` is fully deployed. ## Testing - `just fmt` - `just test -p codex-tools web_search_tool_spec_serializes_expected_wire_shape` - Blocking CI passed, including indexed web-search integration coverage. --- codex-rs/core/src/tools/hosted_spec.rs | 4 ++-- codex-rs/core/src/tools/hosted_spec_tests.rs | 2 +- codex-rs/core/src/tools/spec_plan_tests.rs | 2 +- codex-rs/core/tests/suite/web_search.rs | 4 ++-- codex-rs/tools/src/tool_spec.rs | 6 +++--- codex-rs/tools/src/tool_spec_tests.rs | 5 +++-- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/tools/hosted_spec.rs b/codex-rs/core/src/tools/hosted_spec.rs index 24a7905fa6..83996afcca 100644 --- a/codex-rs/core/src/tools/hosted_spec.rs +++ b/codex-rs/core/src/tools/hosted_spec.rs @@ -18,7 +18,7 @@ pub fn create_image_generation_tool(output_format: &str) -> ToolSpec { } pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option { - let (external_web_access, index_gated_web_access) = match options.web_search_mode { + let (external_web_access, indexed_web_access) = match options.web_search_mode { Some(WebSearchMode::Cached) => (false, None), Some(WebSearchMode::Indexed) => (true, Some(true)), Some(WebSearchMode::Live) => (true, None), @@ -37,7 +37,7 @@ pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option, #[serde(skip_serializing_if = "Option::is_none")] - index_gated_web_access: Option, + indexed_web_access: Option, #[serde(skip_serializing_if = "Option::is_none")] filters: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index b6f599d5d5..406f92c52e 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -67,7 +67,7 @@ fn tool_spec_name_covers_all_variants() { assert_eq!( ToolSpec::WebSearch { external_web_access: Some(true), - index_gated_web_access: None, + indexed_web_access: None, filters: None, user_location: None, search_context_size: None, @@ -200,7 +200,7 @@ fn web_search_tool_spec_serializes_expected_wire_shape() { assert_eq!( serde_json::to_value(ToolSpec::WebSearch { external_web_access: Some(true), - index_gated_web_access: None, + indexed_web_access: Some(true), filters: Some(ResponsesApiWebSearchFilters { allowed_domains: Some(vec!["example.com".to_string()]), }), @@ -218,6 +218,7 @@ fn web_search_tool_spec_serializes_expected_wire_shape() { json!({ "type": "web_search", "external_web_access": true, + "indexed_web_access": true, "filters": { "allowed_domains": ["example.com"], }, From 49f5cb0026599db7317e3778b33d8604a80c9f69 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 20:01:03 -0400 Subject: [PATCH 16/53] Speed up review branch picker via `for-each-ref` (#31464) ## Summary Opening `/review` and choosing a branch currently enumerates branches through `git branch`, which performs broader branch presentation work. In a large repo, it isn't hard for this to timeout completely. This PR instead uses `git for-each-ref` scoped directly to `refs/heads` so the picker reads only local branch refs. It preserves the existing sorting and default-branch promotion while excluding remote-tracking refs and detached-HEAD display rows. --- codex-rs/git-utils/src/info.rs | 46 ++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index d14cd1e0b1..115e26110d 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -864,8 +864,11 @@ fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> { /// Returns a list of local git branches. /// Includes the default branch at the beginning of the list, if it exists. pub async fn local_git_branches(cwd: &Path) -> Vec { - let mut branches: Vec = if let Some(out) = - run_git_command_with_timeout(&["branch", "--format=%(refname:short)"], cwd).await + let mut branches: Vec = if let Some(out) = run_git_command_with_timeout( + &["for-each-ref", "--format=%(refname:short)", "refs/heads"], + cwd, + ) + .await && out.status.success() { String::from_utf8_lossy(&out.stdout) @@ -945,6 +948,45 @@ mod tests { } } + #[tokio::test] + async fn local_git_branches_excludes_detached_head_entry() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let repo = temp_dir.path(); + let envs = vec![ + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_CONFIG_NOSYSTEM", "1"), + ]; + let run_git = |args: &[&str]| { + let status = std::process::Command::new("git") + .envs(envs.clone()) + .args(args) + .current_dir(repo) + .status() + .expect("run Git command"); + assert_eq!(status.code(), Some(0), "Git command failed: {args:?}"); + }; + + run_git(&["init", "-q", "--initial-branch=main"]); + run_git(&[ + "-c", + "user.name=Codex Tests", + "-c", + "user.email=codex-tests@example.com", + "commit", + "--allow-empty", + "-q", + "-m", + "initial", + ]); + run_git(&["branch", "feature/local"]); + run_git(&["checkout", "--detach", "-q"]); + + assert_eq!( + local_git_branches(repo).await, + vec!["main".to_string(), "feature/local".to_string()] + ); + } + #[cfg(unix)] #[tokio::test] async fn fsmonitor_override_rejects_configured_helper() { From 77b766c6ee6d1a7ce9092d06edc09e8592e59a86 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 17:02:16 -0700 Subject: [PATCH 17/53] ci: parameterize Cargo target paths (#31332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Prepare CI jobs for a later build-output relocation without changing where they write today. ## What - Export `CARGO_TARGET_DIR` from `setup-ci` at the existing `codex-rs/target` path. - Route nextest, release, artifact, and signing paths through `CARGO_TARGET_DIR`. - Require V8 staging callers to pass an explicit target directory while preserving the existing upstream path. ## Manual validation - Ran `just test-github-scripts`. - Parsed GitHub Actions YAML with `yq`. ## Stack - [#31332](https://github.com/openai/codex/pull/31332) — parameterize Cargo target paths - [#31356](https://github.com/openai/codex/pull/31356) — Windows 2025 runner bump - [#31357](https://github.com/openai/codex/pull/31357) — Dev Drive I/O routing --- .github/actions/setup-ci/action.yml | 6 +++++ .github/actions/windows-code-sign/action.yml | 2 +- .github/scripts/rusty_v8_bazel.py | 18 ++++++++++----- .github/scripts/test_rusty_v8_bazel.py | 17 +++++++++----- .../rust-ci-full-nextest-platform.yml | 14 ++++++------ .github/workflows/rust-ci-full.yml | 2 +- .github/workflows/rust-release-windows.yml | 22 +++++++++---------- .github/workflows/rusty-v8-release.yml | 2 +- .github/workflows/v8-canary.yml | 10 +++++---- 9 files changed, 56 insertions(+), 37 deletions(-) diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml index 0924e29616..23c78a4952 100644 --- a/.github/actions/setup-ci/action.yml +++ b/.github/actions/setup-ci/action.yml @@ -4,6 +4,12 @@ description: Prepare common tools and environment shared by CI jobs. runs: using: composite steps: + # TODO(anp): Move this under CI_BUILD_ROOT once all consumers use the + # shared build-path contract. + - name: Configure Cargo target directory + shell: bash + run: echo "CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/codex-rs/target}" >> "$GITHUB_ENV" + - name: Prefer the Git CLI for Cargo git dependencies shell: bash run: echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" >> "$GITHUB_ENV" diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index 634d647e06..24f8632979 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -48,7 +48,7 @@ runs: { echo "files<> "$GITHUB_OUTPUT" diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 329d3f6c54..93360edfbf 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -258,21 +258,27 @@ def stage_artifacts( print(staged_checksums) -def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: +def upstream_release_pair_paths( + target: str, + target_dir: Path, +) -> tuple[Path, Path]: lib_name = ( "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" ) - gn_out = source_root / "target" / target / "release" / "gn_out" + gn_out = target_dir / target / "release" / "gn_out" return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" def stage_upstream_release_pair( - source_root: Path, target: str, output_dir: Path, + target_dir: Path, sandbox: bool = False, ) -> None: - lib_path, binding_path = upstream_release_pair_paths(source_root, target) + lib_path, binding_path = upstream_release_pair_paths( + target, + target_dir, + ) stage_artifacts(target, lib_path, binding_path, output_dir, sandbox) @@ -330,7 +336,7 @@ def parse_args() -> argparse.Namespace: "stage-upstream-release-pair" ) stage_upstream_release_pair_parser.add_argument( - "--source-root", type=Path, required=True + "--target-dir", type=Path, required=True ) stage_upstream_release_pair_parser.add_argument("--target", required=True) stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) @@ -373,9 +379,9 @@ def main() -> int: return 0 if args.command == "stage-upstream-release-pair": stage_upstream_release_pair( - source_root=args.source_root, target=args.target, output_dir=Path(args.output_dir), + target_dir=args.target_dir, sandbox=args.sandbox, ) return 0 diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 0b5c03f436..b90121bb43 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -205,8 +205,8 @@ class RustyV8BazelTest(unittest.TestCase): ), ), rusty_v8_bazel.upstream_release_pair_paths( - Path("/tmp/rusty_v8"), "x86_64-apple-darwin", + Path("/tmp/rusty_v8/target"), ), ) self.assertEqual( @@ -221,25 +221,30 @@ class RustyV8BazelTest(unittest.TestCase): ), ), rusty_v8_bazel.upstream_release_pair_paths( - Path("/tmp/rusty_v8"), "x86_64-pc-windows-msvc", + Path("/tmp/rusty_v8/target"), ), ) def test_stage_upstream_release_pair(self) -> None: - with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: - source_root = Path(source_dir) + with ( + TemporaryDirectory() as target_dir, + TemporaryDirectory() as output_dir, + ): gn_out = ( - source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out" + Path(target_dir) + / "x86_64-pc-windows-msvc" + / "release" + / "gn_out" ) (gn_out / "obj").mkdir(parents=True) (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") (gn_out / "src_binding.rs").write_text("binding") rusty_v8_bazel.stage_upstream_release_pair( - source_root, "x86_64-pc-windows-msvc", Path(output_dir), + Path(target_dir), sandbox=True, ) diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml index 825c2d73d6..5f22c74b73 100644 --- a/.github/workflows/rust-ci-full-nextest-platform.yml +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -206,7 +206,7 @@ jobs: --profile ${{ inputs.profile }} \ -p codex-linux-sandbox \ --bin codex-linux-sandbox - cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/" + cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/" else cargo build \ --target ${{ inputs.target }} \ @@ -214,8 +214,8 @@ jobs: -p codex-windows-sandbox \ --bin codex-windows-sandbox-setup \ --bin codex-command-runner - cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/" - cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/" + cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/" + cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/" fi - name: Upload Cargo timings (nextest) @@ -223,7 +223,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cargo-timings-rust-ci-nextest-${{ inputs.target }}-${{ inputs.profile }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html + path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html if-no-files-found: warn - name: Upload nextest archive @@ -369,13 +369,13 @@ jobs: if [[ "${RUNNER_OS}" == "Linux" ]]; then helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" - helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + helper_target_dir="${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}" mkdir -p "${helper_target_dir}" cp "${helper_dir}/codex-linux-sandbox" "${helper_target_dir}/" chmod +x "${helper_target_dir}/codex-linux-sandbox" elif [[ "${RUNNER_OS}" == "Windows" ]]; then helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" - helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + helper_target_dir="${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}" mkdir -p "${helper_target_dir}" cp "${helper_dir}/codex-windows-sandbox-setup.exe" "${helper_target_dir}/" cp "${helper_dir}/codex-command-runner.exe" "${helper_target_dir}/" @@ -423,7 +423,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: nextest-junit-rust-ci-${{ inputs.artifact_id }}-shard-${{ matrix.shard }} - path: codex-rs/target/nextest/default/junit.xml + path: ${{ env.CARGO_TARGET_DIR }}/nextest/default/junit.xml if-no-files-found: warn - name: Tear down remote test env diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index dc38aaa86d..c50c032067 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -403,7 +403,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html + path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html if-no-files-found: warn # Save caches explicitly; make non-fatal so cache packaging diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index ccb2ee13f7..2fa8a52b7f 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -110,13 +110,13 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html + path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html if-no-files-found: warn - name: Stage Windows binaries shell: bash run: | - release_dir="target/${{ matrix.target }}/release" + release_dir="$CARGO_TARGET_DIR/${{ matrix.target }}/release" output_dir="$release_dir/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do @@ -139,7 +139,7 @@ jobs: with: name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }} path: | - codex-rs/target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/* + ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/* build-windows-symbols: needs: @@ -168,14 +168,14 @@ jobs: with: pattern: windows-binaries-${{ matrix.target }}-* merge-multiple: true - path: codex-rs/target/${{ matrix.target }}/release + path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release - name: Build symbols archive shell: bash run: | bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ --target "${{ matrix.target }}" \ --artifact-name "${{ matrix.target }}" \ - --release-dir "target/${{ matrix.target }}/release" \ + --release-dir "${CARGO_TARGET_DIR}/${{ matrix.target }}/release" \ --archive-dir "symbols-dist/${{ matrix.target }}" \ --binaries "${WINDOWS_BINARIES}" - name: Upload symbols archive @@ -226,26 +226,26 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-binaries-${{ matrix.target }}-primary - path: codex-rs/target/${{ matrix.target }}/release + path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release - name: Download prebuilt Windows helper binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-binaries-${{ matrix.target }}-helpers - path: codex-rs/target/${{ matrix.target }}/release + path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release - name: Download prebuilt Windows app-server binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-binaries-${{ matrix.target }}-app-server - path: codex-rs/target/${{ matrix.target }}/release + path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release - name: Verify binaries shell: bash run: | set -euo pipefail for binary in ${WINDOWS_BINARIES}; do - ls -lh "target/${{ matrix.target }}/release/${binary}.exe" + ls -lh "$CARGO_TARGET_DIR/${{ matrix.target }}/release/${binary}.exe" done - name: Sign Windows binaries with Azure Trusted Signing @@ -267,7 +267,7 @@ jobs: mkdir -p "$dest" for binary in ${WINDOWS_BINARIES}; do - cp "target/${{ matrix.target }}/release/${binary}.exe" \ + cp "$CARGO_TARGET_DIR/${{ matrix.target }}/release/${binary}.exe" \ "$dest/${binary}-${{ matrix.target }}.exe" done @@ -291,7 +291,7 @@ jobs: bash "$archive_script" \ --target "$target" \ --bundle "{}" \ - --entrypoint-dir "target/$target/release" \ + --entrypoint-dir "$CARGO_TARGET_DIR/$target/release" \ --archive-dir "dist/$target" - name: Build Python runtime wheel diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 92be3761dd..a2f620aa8f 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -392,7 +392,7 @@ jobs: run: | set -euo pipefail python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \ - --source-root upstream-rusty-v8 \ + --target-dir upstream-rusty-v8/target \ --target "${TARGET}" \ --output-dir "dist/${TARGET}" \ --sandbox diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index f338c341e4..0a1cba4498 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -308,6 +308,8 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/upstream-rusty-v8/target strategy: fail-fast: false matrix: @@ -364,8 +366,8 @@ jobs: uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: | - upstream-rusty-v8/target/sccache - upstream-rusty-v8/target/${{ matrix.target }}/release/gn_out + ${{ env.CARGO_TARGET_DIR }}/sccache + ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release/gn_out key: rusty-v8-source-${{ matrix.target }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }} restore-keys: | rusty-v8-source-${{ matrix.target }}-sandbox- @@ -374,7 +376,7 @@ jobs: shell: pwsh env: SCCACHE_CACHE_SIZE: 256M - SCCACHE_DIR: ${{ github.workspace }}/upstream-rusty-v8/target/sccache + SCCACHE_DIR: ${{ env.CARGO_TARGET_DIR }}/sccache SCCACHE_IDLE_TIMEOUT: 0 run: | $version = "v0.8.2" @@ -409,7 +411,7 @@ jobs: run: | set -euo pipefail python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \ - --source-root upstream-rusty-v8 \ + --target-dir "${CARGO_TARGET_DIR}" \ --target "${TARGET}" \ --output-dir "dist/${TARGET}" \ --sandbox From e60af81b4fa88c13c0a1e4f5e031dcb9e34364c3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 7 Jul 2026 17:05:05 -0700 Subject: [PATCH 18/53] refactor: unify external auth resolution (#31421) ## Summary External auth had two paths: provider-command credentials were resolved through `ExternalAuth`, while app-provided ChatGPT credentials were installed separately and only used the provider for refresh. `ExternalAuth` also declared an auth mode independently from the `CodexAuth` value it returned, so the declaration and credentials could disagree. This change makes the provider-owned `CodexAuth` authoritative for initial resolution, credential kind, and refresh. - remove `ExternalAuth::auth_mode` and require providers to return their current auth from `resolve` - route external auth registration, resolution, and unauthorized refresh through `AuthManager::set_external_auth` - keep the last resolved credential with its provider so synchronous consumers and unauthorized recovery observe the credential's actual mode - make the app-server bridge own both initial and refreshed ChatGPT credentials, and detach it on logout - keep model listing free of auth-refresh side effects, including in offline mode Provider-command auth still follows its configured cache interval. App-provided ChatGPT auth still asks the parent app once after a `401` and retries the request once. Stacked on #31355. ## Testing - `just test -p codex-login` - `just test -p codex-models-manager` - focused `codex-app-server` tests for external login/logout, unauthorized refresh, and workspace mismatch --- codex-rs/app-server/src/external_auth.rs | 95 +++++++ codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/message_processor.rs | 84 ------ codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/account_processor.rs | 12 +- codex-rs/app-server/tests/suite/v2/account.rs | 21 ++ .../app-server/tests/suite/v2/app_list.rs | 91 ++++++- codex-rs/cloud-config/src/service_tests.rs | 147 ++++++----- codex-rs/login/src/auth/auth_tests.rs | 2 +- codex-rs/login/src/auth/external_bearer.rs | 15 +- codex-rs/login/src/auth/manager.rs | 246 +++++++++--------- codex-rs/models-manager/src/manager_tests.rs | 27 +- 12 files changed, 433 insertions(+), 309 deletions(-) create mode 100644 codex-rs/app-server/src/external_auth.rs diff --git a/codex-rs/app-server/src/external_auth.rs b/codex-rs/app-server/src/external_auth.rs new file mode 100644 index 0000000000..d00777f710 --- /dev/null +++ b/codex-rs/app-server/src/external_auth.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; +use std::sync::RwLock; + +use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; +use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_app_server_protocol::ServerRequestPayload; +use codex_login::CodexAuth; +use codex_login::ExternalAuthFuture; +use codex_login::auth::ExternalAuth; +use codex_login::auth::ExternalAuthRefreshContext; +use codex_login::auth::ExternalAuthRefreshReason; +use tokio::time::Duration; +use tokio::time::timeout; + +use crate::outgoing_message::OutgoingMessageSender; + +const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + +pub(crate) struct ExternalAuthBridge { + outgoing: Arc, + auth: RwLock, +} + +impl ExternalAuthBridge { + pub(crate) fn new(outgoing: Arc, auth: CodexAuth) -> Self { + Self { + outgoing, + auth: RwLock::new(auth), + } + } + + async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result { + let reason = match context.reason { + ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized, + }; + let params = ChatgptAuthTokensRefreshParams { + reason, + previous_account_id: context.previous_account_id, + }; + + let (request_id, rx) = self + .outgoing + .send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params)) + .await; + let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await { + Ok(result) => { + let result = result.map_err(|err| { + std::io::Error::other(format!("auth refresh request canceled: {err}")) + })?; + result.map_err(|err| { + std::io::Error::other(format!( + "auth refresh request failed: code={} message={}", + err.code, err.message + )) + })? + } + Err(_) => { + let _canceled = self.outgoing.cancel_request(&request_id).await; + return Err(std::io::Error::other(format!( + "auth refresh request timed out after {}s", + EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs() + ))); + } + }; + + let response: ChatgptAuthTokensRefreshResponse = + serde_json::from_value(result).map_err(std::io::Error::other)?; + let auth = CodexAuth::from_external_chatgpt_tokens( + response.access_token.as_str(), + response.chatgpt_account_id.as_str(), + response.chatgpt_plan_type.as_deref(), + )?; + *self + .auth + .write() + .map_err(|_| std::io::Error::other("external auth lock is poisoned"))? = auth.clone(); + Ok(auth) + } +} + +impl ExternalAuth for ExternalAuthBridge { + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { + self.auth + .read() + .map(|auth| auth.clone()) + .map_err(|_| std::io::Error::other("external auth lock is poisoned")) + }) + } + + fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(ExternalAuthBridge::refresh(self, context)) + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index fa6b6cd811..91da0c1645 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -96,6 +96,7 @@ mod current_time; mod dynamic_tools; mod error_code; mod extensions; +mod external_auth; mod filters; mod fs_watch; mod fuzzy_file_search; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 29ace7d99f..ca122920bd 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -50,9 +50,6 @@ use crate::transport::AppServerTransport; use crate::transport::RemoteControlHandle; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppServerRpcTransport; -use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; -use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; -use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; @@ -63,7 +60,6 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; use codex_chatgpt::workspace_settings; @@ -74,12 +70,7 @@ use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; -use codex_login::CodexAuth; -use codex_login::auth::ExternalAuth; -use codex_login::auth::ExternalAuthRefreshContext; -use codex_login::auth::ExternalAuthRefreshReason; use codex_protocol::ThreadId; -use codex_protocol::auth::AuthMode as LoginAuthMode; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; use codex_rollout::StateDbHandle; @@ -95,7 +86,6 @@ use tracing::Instrument; use crate::models_refresh_worker::ModelsRefreshWorker; -const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); fn deserialize_client_request( @@ -109,77 +99,6 @@ fn deserialize_client_request( }) } -#[derive(Clone)] -struct ExternalAuthRefreshBridge { - outgoing: Arc, -} - -impl ExternalAuthRefreshBridge { - fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason { - match reason { - ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized, - } - } - - async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result { - let params = ChatgptAuthTokensRefreshParams { - reason: Self::map_reason(context.reason), - previous_account_id: context.previous_account_id, - }; - - let (request_id, rx) = self - .outgoing - .send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params)) - .await; - - let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await { - Ok(result) => { - // Two failure scenarios: - // 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed. - // 2) client answered with JSON-RPC error payload => propagate code/message. - let result = result.map_err(|err| { - std::io::Error::other(format!("auth refresh request canceled: {err}")) - })?; - result.map_err(|err| { - std::io::Error::other(format!( - "auth refresh request failed: code={} message={}", - err.code, err.message - )) - })? - } - Err(_) => { - let _canceled = self.outgoing.cancel_request(&request_id).await; - return Err(std::io::Error::other(format!( - "auth refresh request timed out after {}s", - EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs() - ))); - } - }; - - let response: ChatgptAuthTokensRefreshResponse = - serde_json::from_value(result).map_err(std::io::Error::other)?; - - CodexAuth::from_external_chatgpt_tokens( - response.access_token.as_str(), - response.chatgpt_account_id.as_str(), - response.chatgpt_plan_type.as_deref(), - ) - } -} - -impl ExternalAuth for ExternalAuthRefreshBridge { - fn auth_mode(&self) -> LoginAuthMode { - LoginAuthMode::Chatgpt - } - - fn refresh( - &self, - context: ExternalAuthRefreshContext, - ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { - Box::pin(ExternalAuthRefreshBridge::refresh(self, context)) - } -} - pub(crate) struct MessageProcessor { outgoing: Arc, models_refresh_worker: ModelsRefreshWorker, @@ -324,9 +243,6 @@ impl MessageProcessor { remote_control_handle, plugin_startup_tasks, } = args; - auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { - outgoing: outgoing.clone(), - })); let thread_state_manager = ThreadStateManager::new(); // The thread store is intentionally process-scoped. Config reloads can // affect per-thread behavior, but they must not move newly started, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 896a166d84..42ec2063b4 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -386,7 +386,6 @@ use codex_login::LoginSuccessPage; use codex_login::LoginSuccessPageBrand; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; -use codex_login::auth::login_with_chatgpt_auth_tokens; use codex_login::complete_device_code_login; use codex_login::login_with_api_key; use codex_login::oauth_client_id; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 2b2c018520..25bb3a7543 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -1,5 +1,6 @@ use super::*; use crate::auth_mode::auth_mode_to_api; +use crate::external_auth::ExternalAuthBridge; use chrono::DateTime; mod rate_limit_resets; @@ -654,14 +655,19 @@ impl AccountRequestProcessor { ))); } - login_with_chatgpt_auth_tokens( - &self.config.codex_home, + let auth = CodexAuth::from_external_chatgpt_tokens( &access_token, &chatgpt_account_id, chatgpt_plan_type.as_deref(), ) .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; - self.auth_manager.reload().await; + self.auth_manager + .set_external_auth(Arc::new(ExternalAuthBridge::new( + Arc::clone(&self.outgoing), + auth, + ))) + .await + .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; self.config_manager.replace_cloud_config_bundle_loader( self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index f0a7b1d087..84a340d0af 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -328,6 +328,27 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { } ); + let logout_id = mcp.send_logout_account_request().await?; + let logout_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(logout_id)), + ) + .await??; + let _: LogoutAccountResponse = to_response(logout_resp)?; + + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!(account.account, None); + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 5fad04929f..a9a74802e2 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -8,7 +8,9 @@ use std::time::Duration; use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; +use app_test_support::ChatGptIdTokenClaims; use app_test_support::TestAppServer; +use app_test_support::encode_id_token; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use axum::Json; @@ -29,6 +31,7 @@ use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadStartParams; @@ -156,6 +159,89 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { Ok(()) } +#[tokio::test] +async fn list_apps_uses_external_chatgpt_auth() -> Result<()> { + let access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("external@example.com") + .plan_type("pro") + .chatgpt_account_id("account-123"), + )?; + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle, _) = start_apps_server_with_delays_and_control_inner( + connectors, + tools, + Duration::ZERO, + Duration::ZERO, + /*workspace_plugins_enabled*/ true, + &access_token, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let login_id = mcp + .send_chatgpt_auth_tokens_login_request( + access_token, + "account-123".to_string(), + Some("pro".to_string()), + ) + .await?; + let login_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(login_id)), + ) + .await??; + assert_eq!( + to_response::(login_response)?, + LoginAccountResponse::ChatgptAuthTokens {} + ); + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { data, next_cursor } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, "beta"); + assert!(data[0].is_accessible); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { let connectors = vec![AppInfo { @@ -1557,6 +1643,7 @@ async fn start_apps_server_with_workspace_plugins_enabled( Duration::ZERO, Duration::ZERO, workspace_plugins_enabled, + "chatgpt-token", ) .await?; Ok((server_url, server_handle)) @@ -1574,6 +1661,7 @@ async fn start_apps_server_with_delays_and_control( directory_delay, tools_delay, /*workspace_plugins_enabled*/ true, + "chatgpt-token", ) .await } @@ -1584,13 +1672,14 @@ async fn start_apps_server_with_delays_and_control_inner( directory_delay: Duration, tools_delay: Duration, workspace_plugins_enabled: bool, + expected_bearer: &str, ) -> Result<(String, JoinHandle<()>, AppsServerControl)> { let response = Arc::new(StdMutex::new( json!({ "apps": connectors, "next_token": null }), )); let tools = Arc::new(StdMutex::new(tools)); let state = AppsServerState { - expected_bearer: "Bearer chatgpt-token".to_string(), + expected_bearer: format!("Bearer {expected_bearer}"), expected_account_id: "account-123".to_string(), response: response.clone(), directory_delay, diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 85c3940bbb..92562520fb 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -19,11 +19,14 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::auth::AgentIdentityAuth; use codex_login::auth::AgentIdentityAuthRecord; +use codex_login::auth::ExternalAuth; +use codex_login::auth::ExternalAuthRefreshContext; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::VecDeque; use std::future::pending; use std::path::Path; +use std::sync::RwLock; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::tempdir; @@ -142,26 +145,20 @@ fn chatgpt_auth_json_with_last_refresh( refresh_token: &str, last_refresh: &str, ) -> serde_json::Value { - chatgpt_auth_json_with_mode( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - last_refresh, - /*auth_mode*/ None, - ) + let fake_jwt = fake_chatgpt_jwt(plan_type, chatgpt_user_id, b"sig"); + json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }) } -fn chatgpt_auth_json_with_mode( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - auth_mode: Option<&str>, -) -> serde_json::Value { +fn fake_chatgpt_jwt(plan_type: &str, chatgpt_user_id: Option<&str>, signature: &[u8]) -> String { let header = json!({ "alg": "none", "typ": "JWT" }); let auth_payload = json!({ "chatgpt_plan_type": plan_type, @@ -174,23 +171,8 @@ fn chatgpt_auth_json_with_mode( }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); - let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let mut auth_json = json!({ - "OPENAI_API_KEY": null, - "tokens": { - "id_token": fake_jwt, - "access_token": access_token, - "refresh_token": refresh_token, - "account_id": account_id, - }, - "last_refresh": last_refresh, - }); - if let Some(auth_mode) = auth_mode { - auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); - } - auth_json + let signature_b64 = URL_SAFE_NO_PAD.encode(signature); + format!("{header_b64}.{payload_b64}.{signature_b64}") } fn test_bundle() -> CloudConfigBundle { @@ -319,6 +301,39 @@ struct UnauthorizedBundleClient { request_count: AtomicUsize, } +struct TestExternalChatgptAuth { + current: RwLock, + refreshed: CodexAuth, + refresh_count: AtomicUsize, +} + +impl ExternalAuth for TestExternalChatgptAuth { + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { + self.current + .read() + .map(|auth| auth.clone()) + .map_err(|_| std::io::Error::other("external auth lock is poisoned")) + }) + } + + fn refresh( + &self, + _context: ExternalAuthRefreshContext, + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { + let refreshed = self.refreshed.clone(); + *self + .current + .write() + .map_err(|_| std::io::Error::other("external auth lock is poisoned"))? = + refreshed.clone(); + self.refresh_count.fetch_add(1, Ordering::SeqCst); + Ok(refreshed) + }) + } +} + impl BundleClient for UnauthorizedBundleClient { async fn get_bundle(&self, _auth: &CodexAuth) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); @@ -855,26 +870,13 @@ async fn get_bundle_surfaces_auth_recovery_message() { } #[tokio::test] -async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { +async fn get_bundle_refreshes_external_auth_after_unauthorized() { let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_mode( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "test-access-token", - "test-refresh-token", - "2025-01-01T00:00:00Z", - Some("chatgptAuthTokens"), - ), - ) - .expect("write auth"); let auth_manager = Arc::new( AuthManager::new( auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, + AuthCredentialsStoreMode::Ephemeral, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), @@ -882,11 +884,32 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { ) .await, ); + let initial_auth = CodexAuth::from_external_chatgpt_tokens( + &fake_chatgpt_jwt("enterprise", Some("user-12345"), b"initial"), + "account-12345", + Some("enterprise"), + ) + .expect("initial external auth"); + let refreshed_token = fake_chatgpt_jwt("enterprise", Some("user-12345"), b"refreshed"); + let refreshed_auth = CodexAuth::from_external_chatgpt_tokens( + &refreshed_token, + "account-12345", + Some("enterprise"), + ) + .expect("refreshed external auth"); + let external_auth = Arc::new(TestExternalChatgptAuth { + current: RwLock::new(initial_auth), + refreshed: refreshed_auth, + refresh_count: AtomicUsize::new(0), + }); + auth_manager + .set_external_auth(external_auth.clone()) + .await + .expect("set external auth"); - let fetcher = Arc::new(UnauthorizedBundleClient { - message: - "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" - .to_string(), + let fetcher = Arc::new(TokenBundleClient { + expected_token: refreshed_token, + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); @@ -897,19 +920,9 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - let err = service - .load_startup_bundle() - .await - .expect_err("cloud config bundle should fail closed"); - assert_eq!( - err, - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - Some(401), - CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, - ) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); + assert_eq!(external_auth.refresh_count.load(Ordering::SeqCst), 1); } #[tokio::test] diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 77d8c4fdfb..0b0bfc882d 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -1069,11 +1069,11 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { let mut auth_config = script.auth_config(); auth_config.refresh_interval_ms = 0; let manager = AuthManager::external_bearer_only(auth_config); + let mut recovery = manager.unauthorized_recovery(); let initial_token = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); - let mut recovery = manager.unauthorized_recovery(); assert!(recovery.has_next()); assert_eq!(recovery.mode_name(), "external"); diff --git a/codex-rs/login/src/auth/external_bearer.rs b/codex-rs/login/src/auth/external_bearer.rs index 1dbd2c3eee..0a276543ea 100644 --- a/codex-rs/login/src/auth/external_bearer.rs +++ b/codex-rs/login/src/auth/external_bearer.rs @@ -2,7 +2,6 @@ use super::manager::CodexAuth; use super::manager::ExternalAuth; use super::manager::ExternalAuthFuture; use super::manager::ExternalAuthRefreshContext; -use codex_protocol::auth::AuthMode; use codex_protocol::config_types::ModelProviderAuthInfo; use std::fmt; use std::io; @@ -30,7 +29,7 @@ impl BearerTokenRefresher { clippy::await_holding_invalid_type, reason = "external bearer cache misses intentionally hold cached_token across the provider command to avoid duplicate refreshes" )] - async fn resolve(&self) -> io::Result> { + async fn resolve(&self) -> io::Result { let access_token = { let mut cached = self.state.cached_token.lock().await; if let Some(cached_token) = cached.as_ref() { @@ -39,9 +38,7 @@ impl BearerTokenRefresher { None => true, }; if should_use_cached_token { - return Ok(Some(CodexAuth::from_api_key( - cached_token.access_token.as_str(), - ))); + return Ok(CodexAuth::from_api_key(cached_token.access_token.as_str())); } } @@ -52,7 +49,7 @@ impl BearerTokenRefresher { }); access_token }; - Ok(Some(CodexAuth::from_api_key(access_token.as_str()))) + Ok(CodexAuth::from_api_key(access_token.as_str())) } async fn refresh(&self, _context: ExternalAuthRefreshContext) -> io::Result { @@ -67,11 +64,7 @@ impl BearerTokenRefresher { } impl ExternalAuth for BearerTokenRefresher { - fn auth_mode(&self) -> AuthMode { - AuthMode::ApiKey - } - - fn resolve(&self) -> ExternalAuthFuture<'_, Option> { + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(BearerTokenRefresher::resolve(self)) } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 06d125422c..dfc46b8f85 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -211,19 +211,12 @@ pub struct ExternalAuthRefreshContext { /// Pluggable auth provider used by `AuthManager` for externally managed auth flows. /// -/// Implementations may either resolve auth eagerly via `resolve()` or provide refreshed -/// auth on demand via `refresh()`. +/// Implementations own the current auth value and any source-specific refresh mechanism. pub trait ExternalAuth: Send + Sync { - /// Indicates which top-level auth mode this external provider supplies. - fn auth_mode(&self) -> AuthMode; + /// Returns the provider's current auth value. + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth>; - /// Returns cached or immediately available auth, if this provider can resolve it synchronously - /// from the caller's perspective. - fn resolve(&self) -> ExternalAuthFuture<'_, Option> { - Box::pin(async { Ok(None) }) - } - - /// Refreshes auth in response to a manager-driven refresh attempt. + /// Refreshes auth and makes the returned value current for future `resolve()` calls. fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>; } @@ -1555,14 +1548,9 @@ enum UnauthorizedRecoveryMode { // 2. Attempt to refresh the token using OAuth token refresh flow. // If after both steps the server still responds with 401 we let the error bubble to the user. // -// For external auth sources, UnauthorizedRecovery retries once. -// -// - External ChatGPT auth tokens (`chatgptAuthTokens`) are refreshed by asking -// the parent app for new tokens through the configured -// `ExternalAuth`, persisting them in the ephemeral auth store, and -// reloading the cached auth snapshot. -// - External bearer auth sources for custom model providers rerun the provider -// auth command without touching disk. +// For external auth sources, UnauthorizedRecovery retries once by asking the +// configured provider to refresh and caching the returned auth through the same +// path used by other auth sources. pub struct UnauthorizedRecovery { manager: Arc, step: UnauthorizedRecoveryStep, @@ -1585,11 +1573,7 @@ impl UnauthorizedRecovery { fn new(manager: Arc) -> Self { let cached_auth = manager.auth_cached(); let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id); - let mode = if manager.has_external_api_key_auth() - || cached_auth - .as_ref() - .is_some_and(CodexAuth::is_external_chatgpt_tokens) - { + let mode = if manager.has_external_auth() { UnauthorizedRecoveryMode::External } else { UnauthorizedRecoveryMode::Managed @@ -1725,9 +1709,7 @@ impl UnauthorizedRecovery { }); } UnauthorizedRecoveryStep::ExternalRefresh => { - self.manager - .refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) - .await?; + self.manager.refresh_token_from_authority().await?; self.step = UnauthorizedRecoveryStep::Done; return Ok(UnauthorizedRecoveryStepResult { auth_state_changed: Some(true), @@ -1980,7 +1962,10 @@ impl AuthManager { /// Current cached auth (clone) without attempting a refresh. pub fn auth_cached(&self) -> Option { - self.inner.read().ok().and_then(|c| c.auth.clone()) + self.inner + .read() + .ok() + .and_then(|cached| cached.auth.clone()) } /// Subscribes to cached auth changes that can affect request recovery. @@ -2003,8 +1988,9 @@ impl AuthManager { /// a guarded reload and then refreshes only if the on-disk auth is unchanged. #[instrument(level = "trace", skip_all)] pub async fn auth(&self) -> Option { - if let Some(auth) = self.resolve_external_api_key_auth().await { - return Some(auth); + if self.has_external_auth() { + self.reload().await; + return self.auth_cached(); } let auth = self.auth_cached()?; @@ -2080,11 +2066,10 @@ impl AuthManager { .await } - /// Force a reload of the auth information from auth.json. Returns - /// whether the auth value changed. + /// Reloads auth from the active source. Returns whether the auth value changed. pub async fn reload(&self) -> bool { tracing::info!("Reloading auth"); - let new_auth = self.load_auth_from_storage().await; + let new_auth = self.load_auth().await; self.set_cached_auth(new_auth) } @@ -2100,7 +2085,7 @@ impl AuthManager { } }; - let new_auth = self.load_auth_from_storage().await; + let new_auth = self.load_auth().await; let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id); if new_account_id.as_deref() != Some(expected_account_id) { @@ -2173,7 +2158,17 @@ impl AuthManager { } } - async fn load_auth_from_storage(&self) -> Option { + async fn load_auth(&self) -> Option { + if let Some(external_auth) = self.external_auth() { + return match self.resolve_external_auth(&external_auth).await { + Ok(auth) => Some(auth), + Err(err) => { + tracing::error!("Failed to resolve external auth: {err}"); + None + } + }; + } + let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); load_auth( &self.codex_home, @@ -2210,15 +2205,22 @@ impl AuthManager { } } - pub fn set_external_auth(&self, external_auth: Arc) { - if let Ok(mut guard) = self.external_auth.write() { - *guard = Some(external_auth); - } + pub async fn set_external_auth( + &self, + external_auth: Arc, + ) -> Result<(), RefreshTokenError> { + let auth = self.resolve_external_auth(&external_auth).await?; + *self.external_auth.write().map_err(|_| { + RefreshTokenError::Transient(std::io::Error::other("external auth lock is poisoned")) + })? = Some(external_auth); + self.commit_external_auth(auth) } pub fn clear_external_auth(&self) { - if let Ok(mut guard) = self.external_auth.write() { - *guard = None; + if let Ok(mut external_auth) = self.external_auth.write() + && external_auth.take().is_some() + { + self.set_cached_auth(/*new_auth*/ None); } } @@ -2300,48 +2302,33 @@ impl AuthManager { self.external_auth .read() .ok() - .and_then(|guard| guard.as_ref().cloned()) - } - - fn external_auth_mode(&self) -> Option { - self.external_auth() - .as_ref() - .map(|external_auth| external_auth.auth_mode()) + .and_then(|external_auth| external_auth.as_ref().map(Arc::clone)) } fn has_external_api_key_auth(&self) -> bool { - self.external_auth_mode() == Some(AuthMode::ApiKey) + self.has_external_auth() + && self + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_api_key_auth) } - async fn resolve_external_api_key_auth(&self) -> Option { - if !self.has_external_api_key_auth() { - return None; - } - - let external_auth = self.external_auth()?; - - match external_auth.resolve().await { - Ok(Some(auth)) if auth.api_auth_mode() == AuthMode::ApiKey => Some(auth), - Ok(Some(auth)) => { - tracing::error!( - resolved_mode = ?auth.api_auth_mode(), - "ignoring non-API-key auth from external API key provider" - ); - None - } - Ok(None) => None, - Err(err) => { - tracing::error!("Failed to resolve external API key auth: {err}"); - None - } - } + async fn resolve_external_auth( + &self, + external_auth: &Arc, + ) -> Result { + let auth = external_auth + .resolve() + .await + .map_err(RefreshTokenError::Transient)?; + self.validate_external_auth(&auth)?; + Ok(auth) } - /// Attempt to refresh the token by first performing a guarded reload. Auth - /// is reloaded from storage only when the account id matches the currently - /// cached account id. If the persisted token differs from the cached token, we - /// can assume that some other instance already refreshed it. If the persisted - /// token is the same as the cached, then ask the token authority to refresh. + /// Attempt to refresh the token by first performing a guarded reload from + /// the active auth source. If the loaded token differs from the cached token, + /// we can assume that the source already refreshed it. Otherwise, ask the + /// token authority to refresh. pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { RefreshTokenError::Permanent(RefreshTokenFailedError::new( @@ -2379,9 +2366,8 @@ impl AuthManager { } /// Attempt to refresh the current auth token from the authority that issued - /// the token. On success, reloads the auth state from disk so other components - /// observe refreshed token. If the token refresh fails, returns the error to - /// the caller. + /// it and update the shared cache. If the token refresh fails, returns the + /// error to the caller. pub async fn refresh_token_from_authority(&self) -> Result<(), RefreshTokenError> { let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { RefreshTokenError::Permanent(RefreshTokenFailedError::new( @@ -2404,24 +2390,26 @@ impl AuthManager { } let attempted_auth = auth.clone(); - let result = match auth { - CodexAuth::ChatgptAuthTokens(_) => { - self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) - .await + let result = if self.has_external_auth() { + self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) + .await + } else { + match auth { + CodexAuth::Chatgpt(chatgpt_auth) => { + let token_data = chatgpt_auth.current_token_data().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "Token data is not available.", + )) + })?; + self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) + .await + } + CodexAuth::ApiKey(_) + | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) + | CodexAuth::BedrockApiKey(_) => Ok(()), } - CodexAuth::Chatgpt(chatgpt_auth) => { - let token_data = chatgpt_auth.current_token_data().ok_or_else(|| { - RefreshTokenError::Transient(std::io::Error::other( - "Token data is not available.", - )) - })?; - self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) - .await - } - CodexAuth::ApiKey(_) - | CodexAuth::AgentIdentity(_) - | CodexAuth::PersonalAccessToken(_) - | CodexAuth::BedrockApiKey(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); @@ -2440,6 +2428,7 @@ impl AuthManager { self.keyring_backend_kind, )?; // Always reload to clear any cached auth (even if file absent). + self.clear_external_auth(); self.reload().await; Ok(removed) } @@ -2459,23 +2448,18 @@ impl AuthManager { self.keyring_backend_kind, )?; // Always reload to clear any cached auth (even if file absent). + self.clear_external_auth(); self.reload().await; Ok(result) } /// Returns the precise kind of credentials backing the current authentication. pub fn get_api_auth_mode(&self) -> Option { - if self.has_external_api_key_auth() { - return Some(AuthMode::ApiKey); - } self.auth_cached().as_ref().map(CodexAuth::api_auth_mode) } /// Returns the effective backend auth mode for the current authentication. pub fn auth_mode(&self) -> Option { - if self.has_external_api_key_auth() { - return Some(AuthMode::ApiKey); - } self.auth_cached().as_ref().map(CodexAuth::auth_mode) } @@ -2517,7 +2501,6 @@ impl AuthManager { "external auth is not configured", ))); }; - let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); let previous_account_id = self .auth_cached() .as_ref() @@ -2531,41 +2514,44 @@ impl AuthManager { .refresh(context) .await .map_err(RefreshTokenError::Transient)?; - if external_auth.auth_mode() == AuthMode::ApiKey { - return Ok(()); + self.validate_external_auth(&refreshed)?; + self.commit_external_auth(refreshed)?; + Ok(()) + } + + fn commit_external_auth(&self, auth: CodexAuth) -> Result<(), RefreshTokenError> { + if auth.is_external_chatgpt_tokens() { + let auth_dot_json = auth.get_current_auth_json().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "external ChatGPT auth tokens are missing auth state", + )) + })?; + // App/connectors paths still construct independent AuthManagers from Config. Mirror + // external ChatGPT auth into the process-local store so those managers see it too. + save_auth( + &self.codex_home, + &auth_dot_json, + AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), + ) + .map_err(RefreshTokenError::Transient)?; } - if refreshed.api_auth_mode() != AuthMode::ChatgptAuthTokens { - return Err(RefreshTokenError::Transient(std::io::Error::other( - "external auth refresh did not return ChatGPT auth tokens", - ))); - } - let account_id = refreshed.get_account_id().ok_or_else(|| { - RefreshTokenError::Transient(std::io::Error::other( - "external ChatGPT auth tokens are missing an account id", - )) - })?; - if let Some(expected_workspace_ids) = forced_chatgpt_workspace_id.as_deref() + + self.set_cached_auth(Some(auth)); + Ok(()) + } + + fn validate_external_auth(&self, auth: &CodexAuth) -> Result<(), RefreshTokenError> { + if let Some(account_id) = auth.get_account_id() + && let Some(expected_workspace_ids) = self.forced_chatgpt_workspace_id() && !expected_workspace_ids.contains(&account_id) { return Err(RefreshTokenError::Transient(std::io::Error::other( format!( - "external auth refresh returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}" + "external auth returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}" ), ))); } - let auth_dot_json = refreshed.get_current_auth_json().ok_or_else(|| { - RefreshTokenError::Transient(std::io::Error::other( - "external ChatGPT auth tokens are missing auth state", - )) - })?; - save_auth( - &self.codex_home, - &auth_dot_json, - AuthCredentialsStoreMode::Ephemeral, - AuthKeyringBackendKind::default(), - ) - .map_err(RefreshTokenError::Transient)?; - self.reload().await; Ok(()) } diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index 2e97dfac43..f1678fe9f2 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -117,12 +117,8 @@ impl TestModelsEndpoint { struct TestExternalApiKeyAuth; impl ExternalAuth for TestExternalApiKeyAuth { - fn auth_mode(&self) -> AuthMode { - AuthMode::ApiKey - } - - fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, Option> { - Box::pin(async { Ok(Some(CodexAuth::from_api_key("test-external-api-key"))) }) + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) }) } fn refresh( @@ -137,8 +133,8 @@ impl ExternalAuth for TestExternalApiKeyAuth { struct TestUnresolvedExternalApiKeyAuth; impl ExternalAuth for TestUnresolvedExternalApiKeyAuth { - fn auth_mode(&self) -> AuthMode { - AuthMode::ApiKey + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Err(std::io::Error::other("unresolved test auth")) }) } fn refresh( @@ -858,7 +854,10 @@ async fn refresh_available_models_skips_network_when_external_api_key_overrides_ let codex_home = tempdir().expect("temp dir"); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - auth_manager.set_external_auth(Arc::new(TestExternalApiKeyAuth)); + auth_manager + .set_external_auth(Arc::new(TestExternalApiKeyAuth)) + .await + .expect("external API key auth should resolve"); let endpoint = TestAuthAwareModelsEndpoint::new( Some(Arc::clone(&auth_manager)), vec![vec![remote_model( @@ -898,7 +897,10 @@ async fn refresh_available_models_uses_cached_chatgpt_when_external_api_key_is_u let codex_home = tempdir().expect("temp dir"); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - auth_manager.set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth)); + auth_manager + .set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth)) + .await + .expect_err("unresolved external auth should be rejected"); let endpoint = TestAuthAwareModelsEndpoint::new( Some(Arc::clone(&auth_manager)), vec![vec![remote_model( @@ -1013,7 +1015,10 @@ async fn static_manager_reads_latest_auth_mode() { vec!["chatgpt-only", "api-model"] ); - auth_manager.set_external_auth(Arc::new(TestExternalApiKeyAuth)); + auth_manager + .set_external_auth(Arc::new(TestExternalApiKeyAuth)) + .await + .expect("external API key auth should resolve"); let api_models = manager.list_models(RefreshStrategy::Online).await; assert_eq!( From dbd2df27d10810b27e1828d0b3d4ab89643eac77 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 17:15:49 -0700 Subject: [PATCH 19/53] test: migrate TestAppServer callers to builder (#31451) ## Why Keep the TestAppServer builder API change reviewable by moving the repository-wide caller migration into a mechanical follow-up. ## What - replace TestAppServer constructor callsites with equivalent builder chains - preserve each caller's automatic-environment, args, program, env, managed-config, plugin-startup, and JSON-logging behavior - make no TestAppServer implementation changes ## Validation - cargo check -p codex-app-server --tests ## Cleanup stack 1. [#31425 test: add TestAppServer builder](https://github.com/openai/codex/pull/31425) 2. [#31451 test: migrate TestAppServer callers to builder](https://github.com/openai/codex/pull/31451) 3. [#31452 test: remove TestAppServer constructors](https://github.com/openai/codex/pull/31452) --- codex-rs/app-server/tests/suite/auth.rs | 90 +++-- .../tests/suite/conversation_summary.rs | 12 +- .../tests/suite/fuzzy_file_search.rs | 18 +- codex-rs/app-server/tests/suite/logging.rs | 10 +- codex-rs/app-server/tests/suite/v2/account.rs | 245 +++++++++----- .../app-server/tests/suite/v2/app_list.rs | 78 ++++- .../app-server/tests/suite/v2/attestation.rs | 8 +- .../app-server/tests/suite/v2/auto_env.rs | 10 +- .../tests/suite/v2/client_metadata.rs | 33 +- .../tests/suite/v2/collaboration_mode_list.rs | 6 +- .../app-server/tests/suite/v2/command_exec.rs | 118 +++++-- .../app-server/tests/suite/v2/compaction.rs | 32 +- .../app-server/tests/suite/v2/config_rpc.rs | 121 +++++-- .../app-server/tests/suite/v2/current_time.rs | 5 +- .../tests/suite/v2/dynamic_tools.rs | 27 +- .../tests/suite/v2/environment_add.rs | 6 +- .../tests/suite/v2/environment_info.rs | 24 +- .../app-server/tests/suite/v2/executor_mcp.rs | 6 +- .../tests/suite/v2/executor_skills.rs | 6 +- .../tests/suite/v2/experimental_api.rs | 48 ++- .../suite/v2/experimental_feature_list.rs | 62 +++- .../tests/suite/v2/external_agent_config.rs | 107 +++--- codex-rs/app-server/tests/suite/v2/fs.rs | 17 +- .../app-server/tests/suite/v2/hooks_list.rs | 54 ++- .../tests/suite/v2/imagegen_extension.rs | 40 ++- .../app-server/tests/suite/v2/initialize.rs | 52 ++- .../tests/suite/v2/marketplace_add.rs | 6 +- .../tests/suite/v2/marketplace_remove.rs | 12 +- .../tests/suite/v2/marketplace_upgrade.rs | 24 +- .../app-server/tests/suite/v2/mcp_resource.rs | 12 +- .../tests/suite/v2/mcp_server_elicitation.rs | 5 +- .../tests/suite/v2/mcp_server_status.rs | 24 +- .../app-server/tests/suite/v2/mcp_tool.rs | 32 +- .../app-server/tests/suite/v2/memory_reset.rs | 6 +- .../app-server/tests/suite/v2/model_list.rs | 32 +- .../v2/model_provider_capabilities_read.rs | 12 +- .../tests/suite/v2/output_schema.rs | 10 +- .../tests/suite/v2/permission_profile_list.rs | 18 +- .../app-server/tests/suite/v2/plan_item.rs | 10 +- .../tests/suite/v2/plugin_install.rs | 222 +++++++++---- .../app-server/tests/suite/v2/plugin_list.rs | 313 +++++++++++++----- .../app-server/tests/suite/v2/plugin_read.rs | 133 ++++++-- .../app-server/tests/suite/v2/plugin_share.rs | 99 ++++-- .../tests/suite/v2/plugin_uninstall.rs | 60 +++- .../app-server/tests/suite/v2/process_exec.rs | 17 +- .../suite/v2/rate_limit_reset_credits.rs | 20 +- .../app-server/tests/suite/v2/rate_limits.rs | 68 +++- .../tests/suite/v2/realtime_conversation.rs | 47 ++- .../tests/suite/v2/recommended_plugins.rs | 12 +- .../tests/suite/v2/remote_control.rs | 89 ++++- .../tests/suite/v2/request_permissions.rs | 5 +- .../tests/suite/v2/request_user_input.rs | 5 +- .../tests/suite/v2/request_validation.rs | 5 +- codex-rs/app-server/tests/suite/v2/review.rs | 30 +- .../tests/suite/v2/safety_check_downgrade.rs | 25 +- .../suite/v2/selected_capability_stack.rs | 18 +- .../tests/suite/v2/selected_environment.rs | 15 +- .../app-server/tests/suite/v2/skills_list.rs | 69 +++- codex-rs/app-server/tests/suite/v2/sleep.rs | 5 +- .../tests/suite/v2/thread_archive.rs | 33 +- .../tests/suite/v2/thread_delete.rs | 11 +- .../app-server/tests/suite/v2/thread_fork.rs | 85 ++++- .../tests/suite/v2/thread_inject_items.rs | 10 +- .../app-server/tests/suite/v2/thread_list.rs | 6 +- .../tests/suite/v2/thread_loaded_list.rs | 10 +- .../tests/suite/v2/thread_memory_mode_set.rs | 11 +- .../tests/suite/v2/thread_metadata_update.rs | 39 ++- .../app-server/tests/suite/v2/thread_read.rs | 86 ++++- .../tests/suite/v2/thread_resume.rs | 259 ++++++++++++--- .../tests/suite/v2/thread_rollback.rs | 10 +- .../tests/suite/v2/thread_settings_update.rs | 36 +- .../tests/suite/v2/thread_shell_command.rs | 23 +- .../app-server/tests/suite/v2/thread_start.rs | 163 +++++++-- .../tests/suite/v2/thread_status.rs | 13 +- .../tests/suite/v2/thread_unarchive.rs | 5 +- .../tests/suite/v2/thread_unsubscribe.rs | 24 +- .../tests/suite/v2/turn_interrupt.rs | 17 +- .../app-server/tests/suite/v2/turn_start.rs | 210 +++++++++--- .../tests/suite/v2/turn_start_zsh_fork.rs | 13 +- .../app-server/tests/suite/v2/turn_steer.rs | 28 +- .../app-server/tests/suite/v2/web_search.rs | 16 +- .../tests/suite/v2/windows_sandbox_setup.rs | 12 +- 82 files changed, 2937 insertions(+), 878 deletions(-) diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index e49e841f65..4869748070 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -115,8 +115,12 @@ async fn get_auth_status_no_auth() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -142,7 +146,11 @@ async fn get_auth_status_with_api_key() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -186,15 +194,16 @@ async fn personal_access_token_without_email_supports_auth_status_and_account_re .await; let authapi_base_url = server.uri(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", Some("at-test-token")), ("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -256,7 +265,11 @@ async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_custom_provider(codex_home.path(), /*requires_openai_auth*/ false)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -289,7 +302,11 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -317,7 +334,11 @@ async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -373,17 +394,18 @@ async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(refresh_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -455,17 +477,18 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(refresh_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -522,17 +545,18 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(refresh_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let failed_request_id = mcp @@ -599,7 +623,11 @@ async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_forced_login(codex_home.path(), "chatgpt")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 64a0e62249..9cb1409d79 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -92,7 +92,11 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { ))?, ); - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -217,7 +221,11 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h let relative_path = rollout_path.strip_prefix(codex_home.path())?.to_path_buf(); let expected = expected_summary(thread_id, normalized_canonical_path(rollout_path)?); - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index 2c30d05fde..2a6c8808b7 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -46,7 +46,11 @@ shell_snapshot = false async fn initialized_mcp(codex_home: &TempDir) -> Result { create_config_toml(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } @@ -236,7 +240,11 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { .to_string(); // Start MCP server and initialize. - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let root_path = root.path().to_string_lossy().to_string(); @@ -302,7 +310,11 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { std::fs::write(root.path().join("alpha.txt"), "contents")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let root_path = root.path().to_string_lossy().to_string(); diff --git a/codex-rs/app-server/tests/suite/logging.rs b/codex-rs/app-server/tests/suite/logging.rs index 8d39a485d1..5628b62b6b 100644 --- a/codex-rs/app-server/tests/suite/logging.rs +++ b/codex-rs/app-server/tests/suite/logging.rs @@ -68,11 +68,11 @@ async fn app_server_emits_structured_tool_call_timing_event() -> Result<()> { "compact", )?; - let mut app_server = TestAppServer::new_with_auto_env_and_json_logging( - codex_home.path(), - "warn,codex_core::tools::parallel=info", - ) - .await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .with_json_logging("warn,codex_core::tools::parallel=info") + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let thread_start_id = 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 84a340d0af..ccf46321e6 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -207,8 +207,12 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { )?; assert!(codex_home.path().join("auth.json").exists()); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let id = mcp.send_logout_account_request().await?; @@ -275,8 +279,12 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -371,8 +379,12 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -497,8 +509,12 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_REFRESHED), )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -605,8 +621,12 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -729,8 +749,12 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_DISALLOWED), )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -846,8 +870,12 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -940,7 +968,11 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -994,7 +1026,11 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { }, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1024,7 +1060,11 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { }, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1057,14 +1097,15 @@ async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Resu mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await; let issuer = mock_server.uri(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; @@ -1122,14 +1163,15 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> mock_oauth_token(&mock_server, &id_token).await; let issuer = mock_server.uri(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; @@ -1199,14 +1241,15 @@ async fn login_account_chatgpt_device_code_failure_notifies_without_account_upda mock_device_code_token_failure(&mock_server, /*status*/ 500).await; let issuer = mock_server.uri(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; @@ -1274,14 +1317,15 @@ async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> { mock_device_code_token_failure(&mock_server, /*status*/ 404).await; let issuer = mock_server.uri(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; @@ -1347,7 +1391,11 @@ async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1413,14 +1461,15 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ (CLIENT_ID_OVERRIDE_ENV_VAR, Some("staging-client")), (LOGIN_ISSUER_ENV_VAR, Some("https://auth.example.com")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1474,17 +1523,18 @@ async fn login_account_chatgpt_redirects_to_hosted_success_page() -> Result<()> mock_oauth_token(&mock_server, &id_token).await; let issuer = mock_server.uri(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), ( LOGIN_OPEN_APP_URL_ENV_VAR, Some("http://localhost:3000/codex/open-app"), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1536,7 +1586,11 @@ async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Initiate the ChatGPT login flow @@ -1611,7 +1665,11 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result }, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1648,7 +1706,11 @@ async fn login_account_chatgpt_includes_forced_workspace_allowlist_query_param() }, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1687,8 +1749,12 @@ async fn get_account_no_auth() -> Result<()> { }, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1719,7 +1785,11 @@ async fn get_account_with_api_key() -> Result<()> { }, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1763,7 +1833,11 @@ async fn get_account_when_auth_not_required() -> Result<()> { }, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1804,7 +1878,11 @@ region = "us-west-2" }, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1847,7 +1925,11 @@ async fn get_account_with_managed_bedrock_provider() -> Result<()> { AuthKeyringBackendKind::default(), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1892,8 +1974,12 @@ async fn get_account_with_chatgpt() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1935,8 +2021,12 @@ async fn get_account_with_chatgpt_without_email() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1998,17 +2088,18 @@ async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<( .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(refresh_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let auth_status_request_id = mcp @@ -2064,8 +2155,12 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index a9a74802e2..35c21e4c68 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -66,7 +66,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); #[tokio::test] async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -132,7 +136,11 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { AuthKeyringBackendKind::default(), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -279,7 +287,12 @@ async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Resu AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -324,7 +337,11 @@ async fn list_apps_includes_plugin_apps_for_chatgpt_auth() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -384,7 +401,10 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let start_request = mcp @@ -497,7 +517,11 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -572,7 +596,11 @@ enabled = false AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -695,7 +723,11 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -845,7 +877,11 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -957,7 +993,11 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1072,7 +1112,11 @@ async fn list_apps_paginates_results() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let first_request = mcp @@ -1198,7 +1242,11 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let initial_request = mcp @@ -1328,7 +1376,11 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let warm_request = mcp diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index ea567a2d43..4499c7a6d2 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -67,8 +67,12 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; let initialized = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_capabilities( diff --git a/codex-rs/app-server/tests/suite/v2/auto_env.rs b/codex-rs/app-server/tests/suite/v2/auto_env.rs index 56190d0f16..e3f3c30755 100644 --- a/codex-rs/app-server/tests/suite/v2/auto_env.rs +++ b/codex-rs/app-server/tests/suite/v2/auto_env.rs @@ -41,7 +41,10 @@ async fn thread_start_with_auto_env_exposes_fixture_cwd_to_model() -> Result<()> "compact", )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let expected_environment = mcp.auto_env_params()?; @@ -110,7 +113,10 @@ async fn auto_env_rejects_explicit_environment_config() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write(codex_home.path().join("environments.toml"), "")?; - let result = TestAppServer::new_with_auto_env(codex_home.path()).await; + let result = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await; let Err(err) = result else { anyhow::bail!("auto-env construction unexpectedly succeeded"); }; diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 6020c332e6..78807a055a 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -59,7 +59,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result /*supports_websockets*/ false, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -155,7 +158,11 @@ async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let ThreadForkResponse { thread, .. } = @@ -239,7 +246,11 @@ async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let ThreadForkResponse { thread, .. } = @@ -339,7 +350,11 @@ async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2() parent_thread_id, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_req = mcp @@ -434,7 +449,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() /*supports_websockets*/ false, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -559,7 +577,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body /*supports_websockets*/ true, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index 31dd810fbd..9db8c8578b 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -28,7 +28,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); #[tokio::test] async fn list_collaboration_modes_returns_presets() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 7cad6c0388..edd704c9fb 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -42,7 +42,11 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "sleep-1".to_string(); @@ -92,7 +96,11 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -140,11 +148,12 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[("COMMAND_EXEC_BASELINE", Some("server"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("COMMAND_EXEC_BASELINE", Some("server"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -198,7 +207,11 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -249,7 +262,11 @@ async fn command_exec_permission_profile_starts_selected_network_proxy() -> Resu codex_home.path(), /*default_permissions*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -297,7 +314,11 @@ async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; insert_networked_permission_profile_config(codex_home.path(), Some("networked"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -355,7 +376,11 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu ":workspace_roots" = "write" "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -406,11 +431,12 @@ async fn command_exec_returns_error_when_local_environment_is_disabled() -> Resu let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -445,7 +471,11 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -483,7 +513,11 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -521,7 +555,11 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -559,7 +597,11 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -597,7 +639,11 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -635,7 +681,11 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -682,7 +732,11 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "stream-cap-1".to_string(); @@ -746,7 +800,11 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "pipe-1".to_string(); @@ -822,7 +880,11 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "tty-1".to_string(); @@ -893,7 +955,11 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "tty-size-1".to_string(); diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index bca34d360d..7f241d28d3 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -81,7 +81,11 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> COMPACT_PROMPT, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -164,8 +168,12 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -271,7 +279,11 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - COMPACT_PROMPT, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -321,7 +333,11 @@ async fn thread_compact_start_rejects_invalid_thread_id() -> Result<()> { COMPACT_PROMPT, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -357,7 +373,11 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { COMPACT_PROMPT, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 945ab16d7e..b1ef93d70f 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -55,7 +55,11 @@ async fn config_requirements_read_includes_allow_remote_control() -> Result<()> codex_home.path().join("requirements.toml"), "allow_remote_control = false\n", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_config_requirements_read_request().await?; @@ -87,7 +91,11 @@ model_reasoning_effort = "medium" service_tier = "fast" "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_config_requirements_read_request().await?; @@ -125,7 +133,11 @@ sandbox_mode = "workspace-write" let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -175,7 +187,11 @@ allowed_domains = ["example.com"] let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -246,7 +262,11 @@ forced_chatgpt_workspace_id = "{WORKSPACE_ID}" ), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -285,7 +305,11 @@ forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"] ), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -327,7 +351,11 @@ location = { country = "US", city = "New York", timezone = "America/New_York" } "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -371,7 +399,11 @@ web_search = true "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -412,7 +444,11 @@ default_tools_approval_mode = "prompt" let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -536,7 +572,11 @@ width = 320 "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -583,7 +623,11 @@ model_reasoning_effort = "high" set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let project_config = AbsolutePathBuf::try_from(project_config_dir)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -653,14 +697,15 @@ writable_roots = [{}] let managed_path_str = managed_path.display().to_string(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[( + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[( "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", Some(&managed_path_str), - )], - ) - .await?; + )]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -749,7 +794,11 @@ model = "gpt-old" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -810,7 +859,11 @@ async fn config_value_write_updates_desktop_settings() -> Result<()> { let codex_home = temp_dir.path().canonicalize()?; write_config(&temp_dir, "")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let write_id = mcp @@ -859,7 +912,11 @@ model = "gpt-old" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let write_id = mcp @@ -907,7 +964,11 @@ model = "gpt-old" "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let write_id = mcp @@ -942,7 +1003,11 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { let codex_home = tmp_dir.path().canonicalize()?; write_config(&tmp_dir, "")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let writable_root = test_tmp_path_buf(); @@ -1014,7 +1079,11 @@ model = "gpt-5.3-spark" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let batch_id = mcp @@ -1070,7 +1139,11 @@ async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> { let codex_home = tmp_dir.path().canonicalize()?; write_config(&tmp_dir, "")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let batch_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/current_time.rs b/codex-rs/app-server/tests/suite/v2/current_time.rs index d4a3a58e40..a8a05f7fe1 100644 --- a/codex-rs/app-server/tests/suite/v2/current_time.rs +++ b/codex-rs/app-server/tests/suite/v2/current_time.rs @@ -40,7 +40,10 @@ async fn current_time_read_round_trip_adds_reminder_to_model_input() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let thread_request_id = app_server diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 4183bd785f..58602b4d08 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -57,7 +57,11 @@ async fn thread_start_normalizes_legacy_dynamic_tools_into_model_request() -> Re let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let visible_schema = json!({ @@ -171,7 +175,10 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let dynamic_tool = DynamicToolSpec::Function(DynamicToolFunctionSpec { @@ -210,7 +217,11 @@ async fn thread_start_rejects_invalid_dynamic_tool_inputs() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; for (dynamic_tools, expected_error) in [ @@ -352,7 +363,10 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let input_schema = json!({ @@ -579,7 +593,10 @@ async fn start_function_dynamic_tool_call(call_id: &str) -> Result Result<()> { Ok::<_, anyhow::Error>(()) }); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; let request_id = app_server diff --git a/codex-rs/app-server/tests/suite/v2/environment_info.rs b/codex-rs/app-server/tests/suite/v2/environment_info.rs index 8b0e82518f..765144968b 100644 --- a/codex-rs/app-server/tests/suite/v2/environment_info.rs +++ b/codex-rs/app-server/tests/suite/v2/environment_info.rs @@ -40,7 +40,11 @@ async fn environment_info_returns_remote_environment_info() -> Result<()> { }); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; add_environment( &mut app_server, @@ -88,7 +92,11 @@ async fn environment_info_accepts_missing_cwd() -> Result<()> { }); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; add_environment( &mut app_server, @@ -125,7 +133,11 @@ async fn environment_info_accepts_missing_cwd() -> Result<()> { #[tokio::test] async fn environment_info_rejects_unknown_environment() -> Result<()> { let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; let request_id = app_server @@ -158,7 +170,11 @@ async fn environment_info_reports_connection_failure() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let exec_server_url = format!("ws://{}", listener.local_addr()?); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; add_environment(&mut app_server, &exec_server_url, Some(50)).await?; diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index aa650c3ecf..b240308a08 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -176,7 +176,11 @@ HTTP_PROXY = {http_proxy} }))?, )?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let selected_thread = start_thread( diff --git a/codex-rs/app-server/tests/suite/v2/executor_skills.rs b/codex-rs/app-server/tests/suite/v2/executor_skills.rs index a15e285d31..42120ccb39 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_skills.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_skills.rs @@ -81,7 +81,11 @@ stream_max_retries = 0 ), )?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 80fdb03171..8a33a33d2c 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -30,7 +30,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn mock_experimental_method_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -62,7 +66,11 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu #[tokio::test] async fn realtime_conversation_start_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -109,7 +117,11 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R #[tokio::test] async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -144,7 +156,11 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result #[tokio::test] async fn thread_settings_update_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -179,7 +195,11 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result #[tokio::test] async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -231,7 +251,11 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( default_client_info(), @@ -270,7 +294,11 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( default_client_info(), @@ -308,7 +336,11 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( default_client_info(), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 797736b7af..6b7f3aea2c 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -50,7 +50,11 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu )) .build() .await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -134,7 +138,12 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_ .mount(&server) .await; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -195,7 +204,12 @@ memories = true "#, )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -229,7 +243,11 @@ memories = true #[tokio::test] async fn experimental_feature_list_rejects_unknown_thread_id() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -264,7 +282,11 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config let project_cwd = codex_home.path().join("project"); std::fs::create_dir_all(&project_cwd)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = set_experimental_feature_enablement( @@ -301,7 +323,11 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> codex_home.path().join("config.toml"), "[features]\nmemories = false\n", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = set_experimental_feature_enablement( @@ -332,7 +358,11 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> #[tokio::test] async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; set_experimental_feature_enablement( @@ -407,7 +437,11 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re #[tokio::test] async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let remote_control_enabled = false; let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]); @@ -425,7 +459,11 @@ async fn experimental_feature_enablement_set_allows_remote_control() -> Result<( #[tokio::test] async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; set_experimental_feature_enablement( @@ -458,7 +496,11 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> #[tokio::test] async fn experimental_feature_enablement_set_ignores_invalid_features() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = set_experimental_feature_enablement( diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 3d4b09579e..6bef968941 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -58,14 +58,15 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl let sqlite_home = TempDir::new()?; let home_dir = codex_home.path().display().to_string(); let sqlite_home_dir = sqlite_home.path().display().to_string(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_dir.as_str())), ("CODEX_SQLITE_HOME", Some(sqlite_home_dir.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -189,17 +190,18 @@ async fn external_agent_config_import_reports_failed_sync_import_in_completion() let home_dir = codex_home.path().display().to_string(); let analytics_capture_file = codex_home.path().join("analytics-events.jsonl"); let analytics_capture_file = analytics_capture_file.display().to_string(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_dir.as_str())), ( "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE", Some(analytics_capture_file.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -335,9 +337,12 @@ async fn external_agent_config_import_completed_tracks_analytics_event() -> Resu external_agent_home(codex_home.path()).join("projects/repo/missing.jsonl"); let project_root = codex_home.path().join("repo"); let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -458,9 +463,12 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -551,9 +559,12 @@ async fn external_agent_config_import_sends_completion_notification_after_pendin )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -633,9 +644,12 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -845,9 +859,12 @@ required = true )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -931,9 +948,12 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1022,9 +1042,12 @@ async fn external_agent_config_import_skips_already_imported_session_versions() )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1115,9 +1138,12 @@ async fn external_agent_config_import_returns_before_background_session_import_f std::fs::write(&session_path, &session_contents)?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1291,9 +1317,12 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index 1f4a4dec38..295b3b2611 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -37,7 +37,11 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); const OPTIONAL_FS_CHANGE_TIMEOUT: Duration = Duration::from_secs(2); async fn initialized_mcp(codex_home: &TempDir) -> Result { - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } @@ -124,11 +128,12 @@ async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result< let codex_home = TempDir::new()?; let absolute_file = codex_home.path().join("absolute.txt"); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 510ed324bc..71cc2dbaa8 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -135,7 +135,11 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { let cwd = TempDir::new()?; write_user_hook_config(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -211,7 +215,11 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { }"#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -299,7 +307,11 @@ async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> { }"#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hooks_list_id = mcp @@ -348,7 +360,11 @@ async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let cwd = TempDir::new()?; write_plugin_hook_config(codex_home.path(), "{ not-json")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -404,7 +420,11 @@ timeout = 5 )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -486,7 +506,11 @@ async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> { write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?; set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let list_id = mcp @@ -556,7 +580,11 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { let cwd = TempDir::new()?; write_user_hook_config(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -707,7 +735,11 @@ command = "python3 {hook_script_path}" ), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp @@ -957,7 +989,11 @@ command = "python3 {hook_script_path}" ), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 8c1643e147..8ce38bd729 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -87,8 +87,12 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_image_generation_turn(&mut mcp).await?; @@ -185,8 +189,12 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> ChatGptAuthFixture::new("access-chatgpt"), AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_image_generation_turn(&mut mcp).await?; @@ -306,8 +314,12 @@ async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<() AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_image_generation_turn(&mut mcp).await?; timeout( @@ -369,8 +381,12 @@ generatedImage(result); AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_image_generation_turn(&mut mcp).await?; timeout( @@ -449,8 +465,12 @@ async fn run_image_edit_test( AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_turn(&mut mcp, input).await?; timeout( diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 69253e760f..aba3f56078 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -33,7 +33,11 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { let codex_home = TempDir::new()?; let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -68,7 +72,11 @@ async fn initialize_probe_does_not_override_originator() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -95,7 +103,11 @@ async fn initialize_codex_backend_does_not_override_originator() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -123,14 +135,15 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> { let codex_home = TempDir::new()?; let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[( + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[( "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", Some("codex_originator_via_env_var"), - )], - ) - .await?; + )]) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -165,11 +178,12 @@ async fn initialize_rejects_invalid_client_name() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)]) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -200,7 +214,10 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -283,7 +300,10 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( ), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_client_info(ClientInfo { diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs index 7dc9f745af..89a9301255 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs @@ -28,7 +28,11 @@ async fn marketplace_add_local_directory_source() -> Result<()> { r#"{"name":"sample"}"#, )?; std::fs::write(source.join("plugins/sample/marker.txt"), "local ref")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs index c6808b8271..3f84180ae1 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs @@ -53,7 +53,11 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { write_installed_marketplace(codex_home.path(), "debug")?; let installed_root = marketplace_install_root(codex_home.path()).join("debug"); - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -91,7 +95,11 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs index 5ed1f2c6f5..6e8f300d0d 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs @@ -169,7 +169,11 @@ async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> { )?; disable_plugin_startup_tasks(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let debug_root = expected_installed_root(codex_home.path(), "debug")?; @@ -223,7 +227,11 @@ async fn marketplace_upgrade_named_marketplace_only() -> Result<()> { )?; disable_plugin_startup_tasks(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let tools_root = expected_installed_root(codex_home.path(), "tools")?; @@ -264,7 +272,11 @@ async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Re )?; disable_plugin_startup_tasks(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let first_response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?; assert!(first_response.errors.is_empty()); @@ -292,7 +304,11 @@ async fn marketplace_upgrade_rejects_unknown_or_non_git_marketplace() -> Result< &configured_local_marketplace_update(&local_source.path().display().to_string()), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; for marketplace_name in ["missing", "local-only"] { diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 1bdf06161f..d973835a16 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -552,7 +552,11 @@ apps = true AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_request_id = mcp @@ -692,7 +696,11 @@ stream_max_retries = 0 AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok((codex_home, mcp)) } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index fd11b44997..c6f1fc03a9 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -422,7 +422,10 @@ impl ElicitationRoundTripFixture { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_capabilities( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 2c34684ada..1036847e43 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -67,7 +67,11 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -132,7 +136,11 @@ async fn mcp_server_status_list_uses_thread_project_local_config() -> Result<()> std::fs::create_dir_all(workspace.path().join(".git"))?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -336,7 +344,11 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -401,7 +413,11 @@ url = "{underscore_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 1f3289e439..cf15e7031a 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -107,7 +107,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -174,7 +177,11 @@ url = "{mcp_server_url}/mcp" #[tokio::test] async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -225,7 +232,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -336,7 +346,11 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let add_environment_id = mcp .send_raw_request( @@ -474,7 +488,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -594,7 +611,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/memory_reset.rs b/codex-rs/app-server/tests/suite/v2/memory_reset.rs index 07e16a3ba6..ab8445378d 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_reset.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_reset.rs @@ -36,7 +36,11 @@ async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result let thread_id = seed_stage1_output(&state_db, codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index d2538629c8..92706a817f 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -96,7 +96,11 @@ fn expected_visible_models() -> Vec { async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -130,7 +134,11 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { async fn list_models_includes_hidden_models() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -216,8 +224,12 @@ openai_base_url = "{server_uri}/v1" AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -273,7 +285,11 @@ openai_base_url = "{server_uri}/v1" async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -322,7 +338,11 @@ async fn list_models_pagination_works() -> Result<()> { async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs index 4143e825ea..58eb5ed6e7 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs @@ -16,7 +16,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); #[tokio::test] async fn read_default_provider_capabilities() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -46,7 +50,11 @@ async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { r#"model_provider = "amazon-bedrock" "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/output_schema.rs b/codex-rs/app-server/tests/suite/v2/output_schema.rs index 4c2a3671b1..668074bd1f 100644 --- a/codex-rs/app-server/tests/suite/v2/output_schema.rs +++ b/codex-rs/app-server/tests/suite/v2/output_schema.rs @@ -32,7 +32,10 @@ async fn turn_start_accepts_output_schema_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -115,7 +118,10 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs index 5d02cafa0a..d291dda172 100644 --- a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs +++ b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs @@ -41,7 +41,11 @@ description = "Inspect without writes." "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -113,7 +117,11 @@ description = "Project-scoped profile." )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let first_request_id = mcp @@ -190,7 +198,11 @@ description = "Project-scoped profile." )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index 0f5f4cc8b3..9717d20bee 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -53,7 +53,10 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let turn = start_plan_mode_turn(&mut mcp).await?; @@ -111,7 +114,10 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let _turn = start_plan_mode_turn(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 975338e11d..9d0a7bcff3 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -72,7 +72,11 @@ const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = #[tokio::test] async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -99,7 +103,11 @@ async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { #[tokio::test] async fn plugin_install_rejects_missing_install_source() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -128,7 +136,11 @@ async fn plugin_install_rejects_missing_install_source() -> Result<()> { #[tokio::test] async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -165,7 +177,11 @@ async fn plugin_install_rejects_remote_marketplace_when_plugins_are_disabled() - plugins = false "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -231,11 +247,12 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> ) .await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -318,11 +335,12 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> { mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -370,7 +388,11 @@ async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -411,7 +433,11 @@ async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -457,7 +483,11 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -489,7 +519,11 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { let codex_home = TempDir::new()?; write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -519,7 +553,11 @@ async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Res mount_empty_remote_installed_plugins(&server).await; mount_backend_analytics_events(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -570,11 +608,12 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -623,7 +662,11 @@ async fn plugin_install_rejects_remote_plugin_not_available() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -686,7 +729,11 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -715,7 +762,11 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul #[tokio::test] async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -756,7 +807,11 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -805,8 +860,12 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = - TestAppServer::new_with_args(codex_home.path(), &["--session-source", "atlas"]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--session-source", "atlas"]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -855,7 +914,11 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -921,7 +984,11 @@ async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -972,11 +1039,12 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; mount_backend_analytics_events(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1023,11 +1091,12 @@ async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_la mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; mount_backend_analytics_events(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1151,7 +1220,11 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let directory_requests_before_install = server_control.directory_request_count(); @@ -1238,7 +1311,11 @@ async fn plugin_install_skips_mcp_oauth_for_chatgpt_dual_surface_plugin() -> Res let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1298,7 +1375,11 @@ async fn plugin_install_starts_mcp_oauth_with_formerly_disallowed_plugin_app() - let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1395,7 +1476,11 @@ async fn plugin_install_starts_mcp_oauth_through_protected_resource_metadata() - let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1458,11 +1543,12 @@ connectors = true let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[("OPENAI_API_KEY", Some("test-api-key"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", Some("test-api-key"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1500,11 +1586,12 @@ async fn plugin_install_starts_remote_mcp_oauth_for_install_response_only_app() mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1552,11 +1639,12 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1634,7 +1722,11 @@ async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Resu let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let directory_requests_before_install = warm_app_directory_cache(&mut mcp, &server_control, "Alpha").await?; @@ -1717,7 +1809,11 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 0e4bb4a16a..e42240ec65 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -101,14 +101,15 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul std::fs::write(marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -166,7 +167,11 @@ enabled = true "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -257,7 +262,11 @@ enabled = true .await; mount_empty_user_installed_plugins(&server).await; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; let request_id = app_server @@ -327,7 +336,11 @@ enabled = true "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -352,7 +365,11 @@ enabled = true #[tokio::test] async fn plugin_list_rejects_relative_cwds() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -429,14 +446,15 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -548,14 +566,16 @@ async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Re .await; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_without_managed_config_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -639,14 +659,16 @@ async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<( .await; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_without_managed_config_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; for _ in 0..2 { @@ -724,14 +746,15 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab )?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -843,14 +866,15 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { }"#, )?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -903,7 +927,11 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( &AbsolutePathBuf::try_from(plugin_root)?, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -995,7 +1023,11 @@ enabled = false "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1142,14 +1174,15 @@ enabled = false let workspace_default = TempDir::new()?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1236,7 +1269,11 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res }"##, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1349,7 +1386,11 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { }"##, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1437,7 +1478,11 @@ enabled = true "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1544,11 +1589,13 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> let installed_path = codex_home .path() .join("plugins/cache/openai-curated-remote/linear/1.2.3"); - let mut mcp = TestAppServer::new_with_env_and_plugin_startup_tasks( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_plugin_startup_tasks() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?; @@ -1623,11 +1670,12 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() .path() .join("plugins/cache/openai-curated-remote/stale"); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1812,7 +1860,11 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1971,7 +2023,11 @@ async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Res .await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2087,7 +2143,11 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_remote_plugi .await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2177,7 +2237,11 @@ async fn plugin_list_propagates_openai_curated_remote_collection_errors_when_rem .await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2217,7 +2281,11 @@ async fn plugin_list_skips_openai_curated_remote_collection_for_api_auth_when_re AuthKeyringBackendKind::default(), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2256,7 +2324,11 @@ async fn plugin_list_includes_api_curated_marketplace_for_api_auth_when_remote_p AuthKeyringBackendKind::default(), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2311,7 +2383,11 @@ async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default( AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2364,7 +2440,11 @@ async fn plugin_list_vertical_kind_noops_when_remote_plugin_enabled() -> Result< AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2418,7 +2498,11 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2497,7 +2581,11 @@ plugin_sharing = true mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2608,7 +2696,11 @@ plugin_sharing = false mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2695,11 +2787,12 @@ plugin_sharing = false ) .await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2773,11 +2866,12 @@ plugin_sharing = false .await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let plugin_installed_request_id = mcp @@ -2848,7 +2942,11 @@ async fn plugin_list_fetches_workspace_directory_kind_when_remote_plugin_disable mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2974,7 +3072,11 @@ plugin_sharing = false mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -3101,7 +3203,11 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -3258,7 +3364,11 @@ plugin_sharing = false AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -3318,7 +3428,11 @@ plugin_sharing = true AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -3448,7 +3562,11 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { .await; } - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -3508,7 +3626,11 @@ remote_plugin = true AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -3544,7 +3666,11 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -3583,7 +3709,12 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> .mount(&server) .await; - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_plugin_startup_tasks() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index e3e83957cd..09cede4581 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -57,7 +57,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn plugin_read_rejects_missing_read_source() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -86,7 +90,11 @@ async fn plugin_read_rejects_missing_read_source() -> Result<()> { #[tokio::test] async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -239,7 +247,11 @@ apps = true .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -384,7 +396,11 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; for remote_marketplace_name in [ @@ -592,7 +608,11 @@ async fn plugin_read_includes_share_url_for_admin_disabled_remote_plugin() -> Re .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -725,7 +745,11 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -777,7 +801,11 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<( .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -829,7 +857,11 @@ remote_plugin = true AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -859,7 +891,11 @@ remote_plugin = true async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { let codex_home = TempDir::new()?; write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -917,7 +953,11 @@ enabled = true )?; write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let marketplace_path = @@ -1021,7 +1061,11 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .expect(1) .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1151,7 +1195,11 @@ async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> .expect(1) .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1207,7 +1255,11 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1265,7 +1317,11 @@ async fn plugin_read_fails_on_malformed_share_mapping() -> Result<()> { "not valid json\n", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1459,7 +1515,11 @@ enabled = false )?; write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let marketplace_path = @@ -1661,7 +1721,11 @@ async fn plugin_read_returns_app_metadata_category() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1750,15 +1814,16 @@ async fn plugin_read_hides_apps_for_api_key_auth() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("CODEX_ACCESS_TOKEN", None), ("CODEX_API_KEY", None), ("OPENAI_API_KEY", None), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1818,7 +1883,11 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1880,7 +1949,11 @@ async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Resul )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1943,7 +2016,11 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result< )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1996,7 +2073,11 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() - )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 3f1c2734f2..493e6ae4e3 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -102,7 +102,11 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?; let request_id = mcp @@ -253,7 +257,11 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; let request_id = mcp @@ -306,7 +314,11 @@ async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -360,7 +372,11 @@ plugin_sharing = false AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -405,7 +421,11 @@ async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -483,7 +503,11 @@ async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -556,7 +580,11 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request("plugin/share/list", Some(json!({}))) @@ -629,15 +657,16 @@ async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> { mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_env.as_str())), ("USERPROFILE", Some(home_env.as_str())), (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -792,15 +821,16 @@ async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> { mount_empty_remote_installed_plugins(&server, "GLOBAL").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_env.as_str())), ("USERPROFILE", Some(home_env.as_str())), (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -883,15 +913,16 @@ async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_env.as_str())), ("USERPROFILE", Some(home_env.as_str())), (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -986,7 +1017,11 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -1067,7 +1102,11 @@ plugin_sharing = false AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -1116,7 +1155,11 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { .mount(&server) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index ea82bca08c..94efd617fc 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -42,7 +42,11 @@ enabled = true "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let params = PluginUninstallParams { @@ -100,7 +104,11 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -162,7 +170,11 @@ async fn plugin_uninstall_rejects_remote_plugin_when_plugins_are_disabled() -> R plugins = false "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -241,7 +253,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; // Simulate a background remote-cache refresh removing the local bundle @@ -336,7 +352,11 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> .join("plugins/cache/openai-curated-remote/linear"); std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -410,7 +430,11 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<( r#"{"name":"skill-improver","version":"1.0.0"}"#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -459,7 +483,11 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() - )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -501,7 +529,11 @@ async fn plugin_uninstall_rejects_remote_plugin_id_with_spaces_before_network_ca codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -536,7 +568,11 @@ async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -571,7 +607,11 @@ async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> { codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs index ac4f6eeda3..de7930e28d 100644 --- a/codex-rs/app-server/tests/suite/v2/process_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -108,11 +108,12 @@ async fn process_spawn_returns_error_when_local_environment_is_disabled() -> Res let codex_home = TempDir::new()?; let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_request_id = mcp @@ -233,7 +234,11 @@ async fn process_kill_terminates_running_process() -> Result<()> { async fn initialized_mcp(codex_home: &Path) -> Result<(MockServer, TestAppServer)> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; create_config_toml(codex_home, &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok((server, mcp)) } diff --git a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs index fbb4e6e940..25da2cd85e 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs @@ -237,14 +237,15 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> { .mount(&server) .await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), (RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR, Some("100")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let consume_id = send_consume_reset_credit(&mut mcp, "request-timeout").await?; let account_id = mcp @@ -287,7 +288,12 @@ async fn chatgpt_test_context() -> Result<(TempDir, MockServer)> { } async fn initialized_app_server(codex_home: &Path) -> Result { - let mut mcp = TestAppServer::new_with_env(codex_home, &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 423385cea9..9c9cbc5ac5 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -42,8 +42,12 @@ const INTERNAL_ERROR_CODE: i64 = -32603; async fn get_account_rate_limits_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_get_account_rate_limits_request().await?; @@ -68,7 +72,11 @@ async fn get_account_rate_limits_requires_auth() -> Result<()> { async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -213,8 +221,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { .mount(&server) .await; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_get_account_rate_limits_request().await?; @@ -371,8 +383,12 @@ async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail( .mount(&server) .await; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_get_account_rate_limits_request().await?; @@ -398,8 +414,12 @@ async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail( async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -428,7 +448,11 @@ async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { async fn send_add_credits_nudge_email_requires_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -482,8 +506,12 @@ async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { .mount(&server) .await; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -526,8 +554,12 @@ async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { .mount(&server) .await; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -570,8 +602,12 @@ async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> { .mount(&server) .await; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 1d8bd90e6d..f85eb526f7 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -291,7 +291,11 @@ impl RealtimeE2eHarness { sandbox, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -657,7 +661,10 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -949,7 +956,10 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1048,7 +1058,10 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript StartupContextConfig::Generated, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1158,7 +1171,11 @@ async fn realtime_list_voices_returns_supported_names() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1230,7 +1247,10 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1334,7 +1354,10 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { StartupContextConfig::Override("startup context"), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -2690,7 +2713,10 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { StartupContextConfig::Override("startup context"), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -2759,7 +2785,10 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs index d440f56903..609dc28a3d 100644 --- a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs +++ b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs @@ -72,11 +72,13 @@ async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Resu )?; let sqlite_home = codex_home.path().to_string_lossy(); - let mut app_server = TestAppServer::new_without_managed_config_with_env( - codex_home.path(), - &[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))], - ) - .await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let access_token = encode_id_token( diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index 30f7f73beb..e6093ad0ee 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -129,7 +129,11 @@ async fn managed_requirements_reject_all_remote_control_rpcs() -> Result<()> { codex_home.path().join("requirements.toml"), "allow_remote_control = false\n", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let notification = timeout( @@ -187,7 +191,11 @@ async fn managed_requirements_allow_remote_control_true_does_not_enable_or_block codex_home.path().join("requirements.toml"), "allow_remote_control = true\n", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_status_read_request().await?; @@ -270,7 +278,12 @@ async fn listen_off_honors_persisted_remote_control_enable() -> Result<()> { }) .await?; - let _app_server = TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; + let _app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--listen", "off"]) + .build() + .await?; let request = timeout(STARTUP_TIMEOUT, read_http_request(&listener)).await??; assert!( request @@ -309,8 +322,12 @@ async fn listen_off_ignores_persisted_enable_when_disabled_by_requirements() -> }) .await?; - let mut app_server = - TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--listen", "off"]) + .build() + .await?; let status = timeout(STARTUP_TIMEOUT, app_server.wait_for_exit()).await??; assert!(!status.success()); timeout(Duration::from_millis(100), listener.accept()) @@ -357,8 +374,12 @@ async fn listen_off_exits_without_persisted_remote_control_enable() -> Result<() .await?; } - let mut app_server = - TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--listen", "off"]) + .build() + .await?; let status = timeout(STARTUP_TIMEOUT, app_server.wait_for_exit()).await??; assert!(!status.success()); } @@ -369,7 +390,11 @@ async fn listen_off_exits_without_persisted_remote_control_enable() -> Result<() async fn remote_control_disable_returns_disabled_status() -> Result<()> { let codex_home = TempDir::new()?; let _listener = configured_remote_control_listener(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_disable_request().await?; @@ -390,7 +415,11 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { #[tokio::test] async fn remote_control_status_read_returns_disabled_status() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_status_read_request().await?; @@ -412,7 +441,11 @@ async fn remote_control_status_read_returns_disabled_status() -> Result<()> { async fn remote_control_enable_returns_connecting_status() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_enable_request().await?; @@ -448,7 +481,11 @@ async fn disable_waits_for_in_flight_durable_enable() -> Result<()> { let websocket_url = backend.websocket_url().to_string(); let state_db = StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; mcp.send_remote_control_enable_request().await?; @@ -480,7 +517,11 @@ async fn rpc_updates_durable_preference_but_ephemeral_does_not() -> Result<()> { let state_db = StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_enable_request().await?; @@ -537,7 +578,11 @@ async fn rpc_updates_durable_preference_but_ephemeral_does_not() -> Result<()> { async fn remote_control_status_read_returns_connecting_status_after_enable() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_enable_request().await?; @@ -572,7 +617,11 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() -> async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_enable_request().await?; @@ -669,7 +718,11 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> async fn pairing_start_works_after_ephemeral_enable() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_ephemeral_enable_request().await?; wait_for_response(&mut mcp, request_id).await?; @@ -707,7 +760,11 @@ async fn pairing_start_works_after_ephemeral_enable() -> Result<()> { async fn remote_control_client_management_works_while_disabled() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = ClientManagementRemoteControlBackend::start(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index ee836de5ed..b30e63edb4 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -37,7 +37,10 @@ async fn request_permissions_round_trip() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index 711858becd..d28b8a3dbc 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -62,7 +62,10 @@ async fn request_user_input_round_trip() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/request_validation.rs b/codex-rs/app-server/tests/suite/v2/request_validation.rs index 4ed239e7f8..1ffdf8cab3 100644 --- a/codex-rs/app-server/tests/suite/v2/request_validation.rs +++ b/codex-rs/app-server/tests/suite/v2/request_validation.rs @@ -30,7 +30,10 @@ async fn request_handlers_reject_remote_image_urls() -> Result<()> { "http://localhost/unused", "http://localhost/unused", )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 37a0c6f219..06f0242476 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -59,7 +59,10 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -173,7 +176,10 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> let codex_home = TempDir::new()?; create_config_toml_with_approval_policy(codex_home.path(), &server.uri(), "untrusted")?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -256,7 +262,10 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -299,7 +308,10 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -377,7 +389,10 @@ async fn review_start_rejects_empty_commit_sha() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -412,7 +427,10 @@ async fn review_start_rejects_empty_custom_instructions() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 57de35ea4e..f7839fee9e 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -49,7 +49,10 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -116,7 +119,10 @@ async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -193,7 +199,10 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -262,7 +271,10 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -336,7 +348,10 @@ async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index 22153f1af7..62817677ac 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -140,7 +140,11 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() ) .await; - let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(fixture.codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let thread_id = start_thread( &mut app_server, @@ -186,7 +190,11 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() drop(app_server); std::fs::remove_file(&fixture.pid_file)?; - let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(fixture.codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server .send_thread_resume_request(ThreadResumeParams { @@ -333,7 +341,11 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> ) .await; - let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(fixture.codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let thread_id = start_thread( &mut app_server, diff --git a/codex-rs/app-server/tests/suite/v2/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs index 513cd25d7f..6dd90eb51a 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_environment.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -45,7 +45,10 @@ async fn thread_start_reports_selected_environment_metadata() -> Result<()> { "mock_provider", "compact", )?; - let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server @@ -105,7 +108,10 @@ async fn thread_start_reports_selected_environment_instruction_source() -> Resul "mock_provider", "compact", )?; - let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let (agents_source, environment_cwd) = { @@ -180,7 +186,10 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { "mock_provider", "compact", )?; - let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let (environment_cwd, environment_shell) = { let auto_env = app_server.auto_env()?; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 5993bf6da2..e64eda0f22 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -186,7 +186,11 @@ enabled = true AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let disablement_request_id = mcp @@ -342,7 +346,11 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result .mount(&server) .await; } - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let stale_skills_list_request_id = mcp @@ -467,7 +475,12 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable .mount(&server) .await; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -513,11 +526,12 @@ async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { "---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n", )?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -557,7 +571,11 @@ async fn skills_list_accepts_relative_cwds() -> Result<()> { let relative_cwd = std::path::PathBuf::from("relative-cwd"); std::fs::create_dir_all(codex_home.path().join(&relative_cwd))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -585,7 +603,11 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> { let first_cwd = TempDir::new()?; let second_cwd = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -621,7 +643,11 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; // Seed the cwd cache before the cwd-local skill exists. @@ -707,7 +733,11 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let set_request_id = mcp @@ -813,7 +843,11 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { ); drop(mcp); - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let skills_request_id = mcp .send_skills_list_request(SkillsListParams { @@ -849,9 +883,12 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( )?; write_skill(&codex_home, "demo")?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let initial_skills_request_id = mcp .send_skills_list_request(SkillsListParams { diff --git a/codex-rs/app-server/tests/suite/v2/sleep.rs b/codex-rs/app-server/tests/suite/v2/sleep.rs index 8d88816fde..96885ae8d8 100644 --- a/codex-rs/app-server/tests/suite/v2/sleep.rs +++ b/codex-rs/app-server/tests/suite/v2/sleep.rs @@ -56,7 +56,10 @@ async fn external_sleep_polls_current_time_and_emits_items() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs index 9de06f1128..31e2c9067d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_archive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -38,7 +38,10 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread. @@ -221,7 +224,11 @@ async fn thread_archive_archives_spawned_descendants() -> Result<()> { ) .await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let archive_id = mcp @@ -342,7 +349,11 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { .join(child_rollout_path.file_name().expect("rollout file name")); std::fs::create_dir_all(&archived_child_path)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let archive_id = mcp @@ -446,7 +457,11 @@ async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> Result< ) .await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let archive_id = mcp @@ -499,7 +514,10 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -539,7 +557,10 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> .await??; primary.clear_message_buffer(); - let mut secondary = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let archive_id = primary diff --git a/codex-rs/app-server/tests/suite/v2/thread_delete.rs b/codex-rs/app-server/tests/suite/v2/thread_delete.rs index c9a64c5ffe..a2aaf7a119 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_delete.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_delete.rs @@ -55,7 +55,11 @@ async fn thread_delete_deletes_spawned_descendants() -> Result<()> { .await?; } - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let delete_id = mcp @@ -122,7 +126,10 @@ fn create_delete_test_rollout(codex_home: &Path, minute: u8, preview: &str) -> R async fn thread_delete_handles_live_threads_before_rollout_exists() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index d7c1707a32..0a2d1ff453 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -118,7 +118,11 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { append_rollout_item_to_path(&original_path, &RolloutItem::SessionMeta(session_meta)).await?; let original_contents = std::fs::read_to_string(&original_path)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -257,7 +261,11 @@ async fn thread_fork_at_last_turn_id_keeps_only_terminal_prefix() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -382,7 +390,11 @@ async fn thread_fork_inherits_explicit_source_name_from_session_index() -> Resul let source_name = "Renamed parent thread"; append_thread_name(codex_home.path(), source_thread_id, source_name).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -433,7 +445,11 @@ async fn thread_fork_can_load_source_by_path() -> Result<()> { "rollout-2025-01-05T12-00-00-{conversation_id}.jsonl" )); - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -473,7 +489,11 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> Some("mock_provider"), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -527,7 +547,11 @@ async fn thread_fork_can_exclude_turns_and_skip_restored_token_usage() -> Result Some("mock_provider"), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -578,7 +602,12 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -622,7 +651,10 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -676,7 +708,11 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -750,17 +786,18 @@ async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> { )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(refresh_token_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -813,7 +850,11 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -967,7 +1008,11 @@ async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Res )?; let side_thread_id = { - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let fork_id = app_server @@ -1012,7 +1057,11 @@ async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Res thread.id }; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let codex_home_path = codex_home.path().to_path_buf(); diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 966e96e6a5..14acdf7be9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -36,7 +36,10 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -153,7 +156,10 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 8ade0bd382..26cf6c56ad 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -50,7 +50,11 @@ use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); async fn init_mcp(codex_home: &Path) -> Result { - let mut mcp = TestAppServer::new(codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs index 62c22e2729..d083d8279f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs @@ -21,7 +21,10 @@ async fn thread_loaded_list_returns_loaded_thread_ids() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -51,7 +54,10 @@ async fn thread_loaded_list_paginates() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let first = start_thread(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs b/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs index 498fb959ad..98395414c7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs @@ -27,7 +27,10 @@ async fn thread_memory_mode_set_updates_loaded_thread_state() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let state_db = init_state_db(codex_home.path()).await?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -79,7 +82,11 @@ async fn thread_memory_mode_set_updates_stored_thread_state() -> Result<()> { )?; let thread_uuid = ThreadId::from_string(&thread_id)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; for mode in [ThreadMemoryMode::Disabled, ThreadMemoryMode::Enabled] { diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index e20a5fab44..fd3f6ab16b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -41,7 +41,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -136,7 +139,10 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -182,7 +188,10 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -244,7 +253,11 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() - /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp @@ -309,7 +322,11 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary( ) .await; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -387,7 +404,11 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() ); fs::rename(&archived_source, &archived_dest)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp @@ -443,7 +464,11 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { )?; let _state_db = init_state_db(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 22adba37c8..4162e7ebd6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -104,7 +104,11 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -159,7 +163,11 @@ async fn thread_read_can_include_turns() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -218,7 +226,11 @@ async fn paginated_stored_thread_allows_metadata_discovery_and_rejects_legacy_hi ThreadHistoryMode::Paginated, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -332,7 +344,11 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -421,7 +437,11 @@ async fn thread_turns_list_supports_requested_items_view() -> Result<()> { append_agent_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "draft")?; append_agent_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "final")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let full = read_single_turn_items_view( @@ -725,7 +745,11 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { archived_dir.join(active_rollout_path.file_name().expect("rollout file name")); std::fs::rename(&active_rollout_path, &archived_rollout_path)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -769,7 +793,11 @@ async fn thread_resume_initial_turns_page_matches_requested_turns_list_page() -> append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let turns_list_id = mcp @@ -840,7 +868,11 @@ async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> R append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -907,7 +939,11 @@ async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -947,7 +983,10 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1007,7 +1046,11 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Set a user-facing thread title. @@ -1155,7 +1198,10 @@ async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1206,7 +1252,10 @@ async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1260,7 +1309,11 @@ async fn thread_items_list_returns_unsupported() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -1298,7 +1351,10 @@ async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index a6e851f1d7..f8fcd84486 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -155,7 +155,10 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread. @@ -202,7 +205,10 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -270,7 +276,11 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul let project_agents = workspace.path().join("AGENTS.md"); std::fs::write(&project_agents, "project instructions")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -348,7 +358,11 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul let extra_root = extra_root_tmp.path().join("extra-root"); std::fs::create_dir_all(&extra_root)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -427,7 +441,12 @@ async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -486,11 +505,13 @@ async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> .as_path() .to_str() .expect("test codex home should be utf-8"); - let mut mcp = TestAppServer::new_without_managed_config_with_env( - &codex_home_path, - &[("CODEX_SQLITE_HOME", Some(sqlite_home))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home_path) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[("CODEX_SQLITE_HOME", Some(sqlite_home))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -582,7 +603,12 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { "codex_work_desktop", )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -626,7 +652,12 @@ async fn thread_resume_running_thread_tracks_thread_originator_in_analytics() -> create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -745,7 +776,11 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -943,7 +978,11 @@ async fn resume_redaction_fixture(client_name: Option<&str>) -> Result Result<()> { /*git_info*/ None, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1100,7 +1143,11 @@ async fn thread_resume_rejects_archived_session_by_id() -> Result<()> { archived_dir.join(active_rollout_path.file_name().expect("rollout file name")), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1139,7 +1186,12 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1244,7 +1296,12 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1343,7 +1400,12 @@ async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1438,7 +1500,12 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> /*git_info*/ None, )?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let goal_id = mcp @@ -1550,7 +1617,12 @@ async fn thread_goal_lifecycle_emits_analytics_and_clear_deletes_goal() -> Resul )?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT.saturating_mul(2), mcp.initialize()).await??; let start_id = mcp @@ -1735,7 +1807,11 @@ async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<( Some("mock_provider"), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1788,7 +1864,11 @@ async fn thread_resume_skips_restored_token_usage_when_turns_are_excluded() -> R Some("mock_provider"), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let first_resume_id = mcp @@ -1896,7 +1976,11 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() format!("{persisted_rollout}{appended_rollout}\n"), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -2020,7 +2104,11 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re format!("{persisted_rollout}{appended_rollout}\n"), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -2214,7 +2302,11 @@ stream_max_retries = 0 .mark_backfill_complete(/*last_watermark*/ None) .await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp @@ -2308,7 +2400,11 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is format!("{persisted_rollout}{appended_rollout}\n"), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -2382,7 +2478,11 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; let thread_id = rollout.conversation_id.clone(); - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -2504,7 +2604,11 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2543,7 +2647,11 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { .await??; primary.clear_message_buffer(); - let mut secondary = TestAppServer::new(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let turn_id = primary @@ -2613,7 +2721,11 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2732,7 +2844,11 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2901,7 +3017,11 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3032,7 +3152,11 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3070,7 +3194,11 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { ) .await??; - let mut secondary = TestAppServer::new(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let resume_id = secondary @@ -3116,7 +3244,11 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3255,7 +3387,11 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let server = create_mock_responses_server_sequence_unchecked(responses).await; create_config_toml(&codex_home, &server.uri())?; - let mut primary = TestAppServer::new(&codex_home).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3476,7 +3612,11 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -3555,17 +3695,18 @@ async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> /*git_info*/ None, )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(refresh_token_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -3649,7 +3790,11 @@ async fn thread_resume_can_load_source_by_external_path() -> Result<()> { )?; let thread_path = rollout_path(external_home.path(), "2025-01-05T12-00-00", &thread_id); - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -3739,7 +3884,11 @@ async fn start_materialized_thread_and_restart( codex_home: &Path, seed_text: &str, ) -> Result { - let mut first_mcp = TestAppServer::new(codex_home).await?; + let mut first_mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; let start_id = first_mcp @@ -3798,7 +3947,11 @@ async fn start_materialized_thread_and_restart( drop(first_mcp); - let mut second_mcp = TestAppServer::new(codex_home).await?; + let mut second_mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; Ok(RestartedThreadFixture { @@ -3829,7 +3982,11 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3867,7 +4024,11 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { ) .await??; - let mut secondary = TestAppServer::new(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let resume_id = secondary diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 328ae529c8..53f549c2e2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -28,7 +28,10 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[tokio::test] async fn thread_rollback_does_not_emit_deprecation_notice_to_codex_tui() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; let initialized = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_client_info(ClientInfo { @@ -80,7 +83,10 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread. diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index 29dbee26d1..bd7b7b366c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -44,7 +44,11 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> write_models_cache(codex_home.path())?; let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; @@ -107,7 +111,11 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() let workspace = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; @@ -157,7 +165,11 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; start_text_turn(&mut mcp, thread.id.clone()).await?; @@ -200,7 +212,11 @@ async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> { write_models_cache(codex_home.path())?; let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; @@ -266,7 +282,11 @@ async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; @@ -300,7 +320,11 @@ async fn turn_start_settings_override_emits_thread_settings_updated() -> Result< let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; timeout( diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index df7b4991d6..03184ab80b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -60,7 +60,11 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -193,11 +197,12 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled() &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_env( - codex_home.as_path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -252,7 +257,11 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index f96653b02a..c98adb40f6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -99,7 +99,10 @@ async fn thread_start_provider_model_fallback_applies_to_configured_model() -> R model = "gpt-5.4-mini" "#, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -122,7 +125,10 @@ model = "gpt-5.4-mini" #[tokio::test] async fn thread_start_warns_for_exec_policy_parse_failure_after_initialize() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let rules_dir = codex_home.path().join("rules"); @@ -200,7 +206,10 @@ async fn thread_start_does_not_repeat_initialize_exec_policy_warning() -> Result std::fs::create_dir_all(&rules_dir)?; std::fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; timeout( DEFAULT_READ_TIMEOUT, @@ -245,7 +254,10 @@ async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> R r#"model_provider = "amazon-bedrock" "#, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let unsupported_with_fallback = start_thread_with_model( @@ -283,7 +295,10 @@ async fn thread_start_provider_model_fallback_ignores_dynamic_catalog() -> Resul let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let response = start_thread_with_model( @@ -306,7 +321,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; // Start server and initialize. - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a v2 thread with an explicit model override. @@ -453,7 +471,10 @@ async fn thread_start_history_mode_accepts_legacy_and_rejects_paginated() -> Res let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -502,7 +523,11 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let extra_root = cwd.join("extra-root"); std::fs::create_dir_all(&extra_root)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -543,7 +568,11 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro profile_root.path(), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -578,7 +607,11 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -613,7 +646,11 @@ async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> R let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -652,7 +689,11 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( let project_agents_path = workspace.path().join("AGENTS.md"); std::fs::write(&project_agents_path, "project instructions")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -699,7 +740,11 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re let project_agents_path = workspace.path().join("AGENTS.md"); std::fs::write(project_agents_path, "")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -742,7 +787,11 @@ async fn thread_start_without_selected_environment_includes_only_global_instruct let workspace = TempDir::new()?; std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -832,7 +881,12 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -882,7 +936,11 @@ model_reasoning_effort = "high" )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -912,7 +970,10 @@ async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let service_tier_id = "experimental-tier-id".to_string(); @@ -942,7 +1003,10 @@ async fn thread_start_accepts_default_service_tier() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -973,7 +1037,10 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1000,7 +1067,10 @@ async fn thread_start_ephemeral_remains_pathless() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1046,7 +1116,10 @@ async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Re let codex_home = TempDir::new()?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1082,7 +1155,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< let codex_home = TempDir::new()?; create_config_toml_with_optional_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1216,17 +1292,18 @@ async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(refresh_token_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1276,7 +1353,11 @@ model_reasoning_effort = "high" "#, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let first_request = mcp @@ -1336,7 +1417,11 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { let nested = repo_root.path().join("nested/project"); std::fs::create_dir_all(&nested)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1374,7 +1459,11 @@ async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> let workspace = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1412,7 +1501,11 @@ async fn thread_start_preserves_untrusted_project_trust() -> Result<()> { std::fs::write(&config_path, config_toml.to_string())?; let config_before = std::fs::read_to_string(&config_path)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1453,7 +1546,11 @@ model_reasoning_effort = "high" set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let config_before = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index 364175b7ae..957eb2ce14 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -28,8 +28,12 @@ async fn thread_status_changed_emits_runtime_updates() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("RUST_LOG", Some("info"))]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("RUST_LOG", Some("info"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -135,7 +139,10 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_capabilities( diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 07999e1650..66d51d2780 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -59,7 +59,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index 9c9ff4b767..a1d5c8b4d7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -42,7 +42,11 @@ async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -121,7 +125,11 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { let final_response_completed = completions.remove(0); create_config_toml(&codex_home, server.uri())?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -253,7 +261,11 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -340,7 +352,11 @@ async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 3dc17780e8..28e9294225 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -57,7 +57,11 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a v2 thread and capture its id. @@ -141,7 +145,10 @@ async fn turn_interrupt_rejects_completed_turn() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; - let mut mcp = TestAppServer::new_with_auto_env(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -236,7 +243,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() .await; create_config_toml(&codex_home, &server.uri(), "untrusted", "read-only")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b42a645a33..62b13a1e52 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -132,7 +132,10 @@ async fn run_local_image_turn(detail: Option) -> Result> &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -229,7 +232,10 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -331,7 +337,10 @@ async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -408,7 +417,10 @@ async fn turn_start_sends_originator_header() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_client_info(ClientInfo { @@ -485,7 +497,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -601,14 +616,15 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( write_test_skill(codex_home.path(), "beta-skill")?; let isolated_home = codex_home.path().to_string_lossy(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(isolated_home.as_ref())), ("USERPROFILE", Some(isolated_home.as_ref())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -701,7 +717,10 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { .expect("bundled model catalog should include a picker model with service tiers"); let service_tier_id = service_tier_model.service_tiers[0].id.clone(); - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -765,7 +784,10 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -867,7 +889,12 @@ async fn turn_start_tracks_thread_originator_in_analytics() -> Result<()> { std::fs::write(config_path, config)?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -992,7 +1019,12 @@ async fn turn_profile_tracks_blocking_tool_and_follow_up_sampling() -> Result<() )?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1093,7 +1125,10 @@ async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1153,7 +1188,10 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1233,7 +1271,10 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() "sandbox_mode = \"read-only\"\n", )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1305,7 +1346,11 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1380,7 +1425,10 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread (v2) and capture its id. @@ -1525,7 +1573,10 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1611,7 +1662,10 @@ async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_de &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1696,7 +1750,10 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1773,7 +1830,10 @@ async fn turn_start_ignores_deprecated_multi_agent_mode() -> Result<()> { &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1850,7 +1910,10 @@ async fn thread_start_ignores_deprecated_multi_agent_mode() -> Result<()> { &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1938,7 +2001,10 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -2054,7 +2120,10 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> /*git_info*/ None, )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let persisted_toml: ConfigToml = toml::from_str(&std::fs::read_to_string( @@ -2185,7 +2254,11 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // thread/start @@ -2331,7 +2404,11 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -2484,7 +2561,11 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // thread/start @@ -2678,7 +2759,11 @@ stream_max_retries = 0 ), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -2789,7 +2874,11 @@ url = "ws://127.0.0.1:1" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; for case in [ @@ -2944,7 +3033,11 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3135,7 +3228,11 @@ async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v let server = create_mock_responses_server_sequence(responses).await; create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3273,7 +3370,11 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { model["apply_patch_tool_type"] = serde_json::Value::from("freeform"); std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3405,7 +3506,10 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() &BTreeMap::from([(Feature::Collab, true)]), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -3623,7 +3727,10 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> { )?; write_models_cache(codex_home.path())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -3801,7 +3908,10 @@ config_file = "./custom-role.toml" ), )?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -3945,7 +4055,11 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -4118,7 +4232,11 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -4265,7 +4383,11 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { "danger-full-access", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -4399,7 +4521,11 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R let workspace = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_request = mcp diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index c6f8f0e198..533f925798 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -746,12 +746,13 @@ async fn create_zsh_test_mcp_process( ) -> Result { let app_server = create_test_package_app_server(codex_home, zsh_path)?; let zdotdir = zdotdir.to_string_lossy().into_owned(); - TestAppServer::new_with_program_and_env( - codex_home, - &app_server, - &[("ZDOTDIR", Some(zdotdir.as_str()))], - ) - .await + TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_program(&app_server) + .with_env_overrides(&[("ZDOTDIR", Some(zdotdir.as_str()))]) + .build() + .await } fn create_test_package_app_server(codex_home: &Path, zsh_path: &Path) -> Result { diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index ed0ea2def5..10ad608ec3 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -50,7 +50,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -136,7 +141,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -250,7 +260,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -387,7 +402,12 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index f8c115d746..f7d05395b4 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -78,8 +78,12 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -196,8 +200,12 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { assert_eq!(completed.item, expected_completed_item); drop(mcp); - let mut reloaded_mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut reloaded_mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.initialize()).await??; let read_req = reloaded_mcp .send_thread_read_request(ThreadReadParams { diff --git a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs index afb2b2ab33..e562c6b8e8 100644 --- a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs +++ b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs @@ -31,7 +31,11 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( "mock_provider", "compact prompt", )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -66,7 +70,11 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( #[tokio::test] async fn windows_sandbox_setup_start_rejects_relative_cwd() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp From 8784de445e6fbbf8c192ac6a7f2ae97209dc610f Mon Sep 17 00:00:00 2001 From: lt-oai Date: Tue, 7 Jul 2026 17:43:19 -0700 Subject: [PATCH 20/53] [codex] Add externally provided Codex auth (#31274) ## Summary Add an in-memory externally provided Codex auth snapshot with explicit runtime capabilities, installed through the existing `ExternalAuth` provider path. ## Testing - `just fmt-check` - `cargo test -p codex-login --lib externally_provided_auth` - `cargo test -p codex-core --lib external_auth_snapshot_is_installed_from_runtime_config` - `cargo test -p codex-model-provider --lib external_auth` - `cargo test -p codex-mcp-extension --test hosted_apps_mcp hosted_apps_mcp_accepts_external_provided_codex_auth` - `cargo check -p codex-app-server -p codex-core-api -p codex-thread-manager-sample` --------- Co-authored-by: pakrym-oai --- .../schema/json/ServerNotification.json | 7 +++ .../codex_app_server_protocol.schemas.json | 7 +++ .../codex_app_server_protocol.v2.schemas.json | 7 +++ .../json/v2/AccountUpdatedNotification.json | 7 +++ .../schema/typescript/AuthMode.ts | 2 +- .../src/protocol/common.rs | 8 ++- codex-rs/app-server/src/auth_mode.rs | 1 + .../request_processors/account_processor.rs | 4 +- codex-rs/cli/src/doctor.rs | 6 ++ codex-rs/cli/src/login.rs | 3 + codex-rs/core-api/src/lib.rs | 6 ++ codex-rs/core/src/client.rs | 1 + codex-rs/core/tests/suite/external_auth.rs | 42 ++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/login/src/auth/auth_headers.rs | 30 ++++++++++ codex-rs/login/src/auth/auth_tests.rs | 55 +++++++++++++++++++ .../login/src/auth/bedrock_api_key_tests.rs | 2 + codex-rs/login/src/auth/manager.rs | 39 ++++++++++++- codex-rs/login/src/auth/mod.rs | 2 + codex-rs/login/src/lib.rs | 1 + codex-rs/model-provider-info/src/lib.rs | 1 + .../src/model_provider_info_tests.rs | 9 +++ .../model-provider/src/amazon_bedrock/mod.rs | 1 + codex-rs/model-provider/src/auth.rs | 28 ++++++++++ codex-rs/model-provider/src/provider.rs | 4 ++ codex-rs/otel/src/lib.rs | 1 + codex-rs/protocol/src/auth.rs | 7 ++- codex-rs/tui/src/app_server_session.rs | 2 +- codex-rs/tui/src/onboarding/auth.rs | 1 + 29 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 codex-rs/core/tests/suite/external_auth.rs create mode 100644 codex-rs/login/src/auth/auth_headers.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e7a533d76b..e025a61a33 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -536,6 +536,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1102135a71..b7526b080d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6951,6 +6951,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 29dfe78034..e1afc7883a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1077,6 +1077,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index 5c1847c56c..710e0ecedd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -25,6 +25,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 77bc037ebe..b248876173 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "headers" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e693e8d451..27a8b0205d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -31,6 +31,11 @@ pub enum AuthMode { #[ts(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] ChatgptAuthTokens, + /// Backend auth supplied as request headers. + #[serde(rename = "headers")] + #[ts(rename = "headers")] + #[strum(serialize = "headers")] + Headers, /// Programmatic Codex auth backed by a registered Agent Identity. #[serde(rename = "agentIdentity")] #[ts(rename = "agentIdentity")] @@ -53,7 +58,7 @@ impl AuthMode { pub fn has_chatgpt_account(self) -> bool { match self { Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, - Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false, + Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false, } } @@ -62,6 +67,7 @@ impl AuthMode { match self { Self::Chatgpt | Self::ChatgptAuthTokens + | Self::Headers | Self::AgentIdentity | Self::PersonalAccessToken => true, Self::ApiKey | Self::BedrockApiKey => false, diff --git a/codex-rs/app-server/src/auth_mode.rs b/codex-rs/app-server/src/auth_mode.rs index 67d3ee61b9..d543470781 100644 --- a/codex-rs/app-server/src/auth_mode.rs +++ b/codex-rs/app-server/src/auth_mode.rs @@ -12,6 +12,7 @@ pub(crate) fn auth_mode_to_api(auth_mode: AuthMode) -> ApiAuthMode { AuthMode::ApiKey => ApiAuthMode::ApiKey, AuthMode::Chatgpt => ApiAuthMode::Chatgpt, AuthMode::ChatgptAuthTokens => ApiAuthMode::ChatgptAuthTokens, + AuthMode::Headers => ApiAuthMode::Headers, AuthMode::AgentIdentity => ApiAuthMode::AgentIdentity, AuthMode::PersonalAccessToken => ApiAuthMode::PersonalAccessToken, AuthMode::BedrockApiKey => ApiAuthMode::BedrockApiKey, diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 25bb3a7543..f4acc5a8e4 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -855,7 +855,9 @@ impl AccountRequestProcessor { let auth_mode = auth_mode_to_api(auth.api_auth_mode()); let (reported_auth_method, token_opt) = if matches!( auth, - CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) + CodexAuth::Headers(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) ) || include_token && permanent_refresh_failure { diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index ef587cd1d4..cfb9316e91 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1327,6 +1327,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { AuthMode::ApiKey => "api_key", AuthMode::Chatgpt => "chatgpt", AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + AuthMode::Headers => "headers", AuthMode::AgentIdentity => "agent_identity", AuthMode::PersonalAccessToken => "personal_access_token", AuthMode::BedrockApiKey => "bedrock_api_key", @@ -1397,6 +1398,9 @@ fn stored_auth_issues( issues.push("external ChatGPT auth is missing refresh metadata"); } } + AuthMode::Headers => { + issues.push("header auth cannot be loaded from auth storage"); + } AuthMode::AgentIdentity => { if auth .agent_identity @@ -2466,6 +2470,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str { AuthMode::ApiKey => "api_key", AuthMode::Chatgpt => "chatgpt", AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + AuthMode::Headers => "headers", AuthMode::AgentIdentity => "agent_identity", AuthMode::PersonalAccessToken => "personal_access_token", AuthMode::BedrockApiKey => "bedrock_api_key", @@ -2605,6 +2610,7 @@ fn provider_auth_reachability_mode_from_auth( Some( AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken, ) diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index d3f9b7b96b..98d56bec12 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -449,6 +449,9 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::Headers => { + unreachable!("header auth cannot be loaded from auth storage") + } AuthMode::AgentIdentity => { eprintln!("Logged in using access token"); std::process::exit(0); diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index c7cfff8a92..a5257ae44f 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -69,7 +69,13 @@ pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; pub use codex_home::CodexHomeUserInstructionsProvider; +pub use codex_login::AuthHeaders; pub use codex_login::AuthManager; +pub use codex_login::CodexAuth; +pub use codex_login::ExternalAuth; +pub use codex_login::ExternalAuthFuture; +pub use codex_login::ExternalAuthRefreshContext; +pub use codex_login::ExternalAuthRefreshReason; pub use codex_login::default_client::set_default_originator; pub use codex_model_provider_info::OPENAI_PROVIDER_ID; pub use codex_model_provider_info::built_in_model_providers; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 0bf33b3993..8c98a3372e 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -2141,6 +2141,7 @@ impl AuthRequestTelemetryContext { AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey", AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken => "Chatgpt", }), diff --git a/codex-rs/core/tests/suite/external_auth.rs b/codex-rs/core/tests/suite/external_auth.rs new file mode 100644 index 0000000000..2aa9c9a0d5 --- /dev/null +++ b/codex-rs/core/tests/suite/external_auth.rs @@ -0,0 +1,42 @@ +use codex_login::AuthHeaders; +use codex_login::CodexAuth; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use reqwest::header::AUTHORIZATION; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderValue; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn header_auth_is_attached_to_responses_requests() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer external")); + headers.insert("x-external-auth", HeaderValue::from_static("enabled")); + let mut builder = test_codex().with_auth(CodexAuth::Headers(AuthHeaders::new(headers))); + let test = builder.build_with_auto_env(&server).await?; + + test.submit_turn("hello").await?; + + let request = response_mock.single_request(); + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer external") + ); + assert_eq!( + request.header("x-external-auth").as_deref(), + Some("enabled") + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 9b22222d7c..e8bb48856c 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -54,6 +54,7 @@ mod exec; mod exec_policy; #[cfg(not(target_os = "windows"))] mod extension_sandbox; +mod external_auth; mod fork_thread; #[cfg(not(target_os = "windows"))] mod guardian_review; diff --git a/codex-rs/login/src/auth/auth_headers.rs b/codex-rs/login/src/auth/auth_headers.rs new file mode 100644 index 0000000000..802f00c4d3 --- /dev/null +++ b/codex-rs/login/src/auth/auth_headers.rs @@ -0,0 +1,30 @@ +use std::fmt; + +use reqwest::header::HeaderMap; + +/// Request headers returned by an external auth provider. +/// +/// The provider owns credential validation, rotation, and persistence. Codex +/// keeps the resolved headers in memory and attaches them to backend requests. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthHeaders { + headers: HeaderMap, +} + +impl AuthHeaders { + pub fn new(headers: HeaderMap) -> Self { + Self { headers } + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } +} + +impl fmt::Debug for AuthHeaders { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuthHeaders") + .field("headers", &"") + .finish() + } +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 0b0bfc882d..780cd86009 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -1093,6 +1093,61 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token")); } +#[derive(Clone)] +struct StaticExternalAuth(CodexAuth); + +impl ExternalAuth for StaticExternalAuth { + 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] +async fn external_auth_provider_can_install_headers() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_static("Bearer external"), + ); + headers.insert( + "x-external-auth", + reqwest::header::HeaderValue::from_static("enabled"), + ); + let auth = CodexAuth::Headers(AuthHeaders::new(headers)); + let codex_home = tempdir().expect("tempdir"); + let manager = AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, + ) + .await; + + manager + .set_external_auth(Arc::new(StaticExternalAuth(auth.clone()))) + .await + .expect("external auth should install"); + + assert_eq!(manager.auth_cached(), Some(auth)); + assert!( + manager + .auth_cached() + .is_some_and(|auth| auth.uses_codex_backend()) + ); + assert!( + !manager + .auth_cached() + .is_some_and(|auth| auth.is_chatgpt_auth()) + ); +} + struct ProviderAuthScript { tempdir: TempDir, command: String, diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 1f8fab68b2..182834af66 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -85,6 +85,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }), @@ -148,6 +149,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }), diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index dfc46b8f85..9509efc348 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -38,6 +38,7 @@ use super::agent_identity::require_agent_identity_authapi_base_url; use super::agent_identity::verified_record_from_jwt; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; +use crate::auth::AuthHeaders; pub use crate::auth::agent_identity::AgentIdentityAuth; pub use crate::auth::agent_identity::AgentIdentityAuthError; pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth; @@ -71,6 +72,7 @@ pub enum CodexAuth { ApiKey(ApiKeyAuth), Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), + Headers(AuthHeaders), AgentIdentity(AgentIdentityAuth), PersonalAccessToken(PersonalAccessTokenAuth), BedrockApiKey(BedrockApiKeyAuth), @@ -145,6 +147,7 @@ impl AgentIdentityBootstrapCooldown { impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { match (self, other) { + (Self::Headers(a), Self::Headers(b)) => a == b, (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b, (Self::BedrockApiKey(a), Self::BedrockApiKey(b)) => a == b, _ => self.api_auth_mode() == other.api_auth_mode(), @@ -308,6 +311,11 @@ impl CodexAuth { }; return Ok(Self::BedrockApiKey(auth)); } + if auth_mode == AuthMode::Headers { + return Err(std::io::Error::other( + "externally provided auth cannot be loaded from auth storage.", + )); + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?; @@ -327,6 +335,9 @@ impl CodexAuth { } AuthMode::ChatgptAuthTokens => Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })), AuthMode::ApiKey => unreachable!("api key mode is handled above"), + AuthMode::Headers => { + unreachable!("externally provided auth is never loaded from auth storage") + } AuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), AuthMode::PersonalAccessToken => { unreachable!("personal access token mode is handled above") @@ -409,6 +420,7 @@ impl CodexAuth { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, + Self::Headers(_) => AuthMode::Headers, Self::AgentIdentity(_) => AuthMode::AgentIdentity, Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, Self::BedrockApiKey(_) => AuthMode::BedrockApiKey, @@ -421,6 +433,7 @@ impl CodexAuth { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) => AuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => AuthMode::ChatgptAuthTokens, + Self::Headers(_) => AuthMode::Headers, Self::AgentIdentity(_) => AuthMode::AgentIdentity, Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, Self::BedrockApiKey(_) => AuthMode::BedrockApiKey, @@ -448,7 +461,10 @@ impl CodexAuth { } fn supports_unauthorized_recovery(&self) -> bool { - matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + matches!( + self, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::Headers(_) + ) } /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. @@ -457,6 +473,7 @@ impl CodexAuth { Self::ApiKey(auth) => Some(auth.api_key.as_str()), Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) + | Self::Headers(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => None, @@ -487,6 +504,9 @@ impl CodexAuth { Self::AgentIdentity(_) => Err(std::io::Error::other( "agent identity auth does not expose a bearer token", )), + Self::Headers(_) => Err(std::io::Error::other( + "header auth does not expose a bearer token", + )), Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()), Self::BedrockApiKey(_) => Err(std::io::Error::other( "Bedrock API key auth does not expose a Codex bearer token", @@ -497,6 +517,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account id. pub fn get_account_id(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()), _ => self.get_current_token_data().and_then(|t| t.account_id), @@ -506,6 +527,7 @@ impl CodexAuth { /// Returns false if Codex backend auth omits the FedRAMP claim. pub fn is_fedramp_account(&self) -> bool { match self { + Self::Headers(_) => false, Self::AgentIdentity(auth) => auth.is_fedramp_account(), Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), _ => self @@ -517,6 +539,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account email. pub fn get_account_email(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => auth.email().map(str::to_string), Self::PersonalAccessToken(auth) => auth.email().map(str::to_string), _ => self.get_current_token_data().and_then(|t| t.id_token.email), @@ -526,6 +549,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose a ChatGPT user id. pub fn get_chatgpt_user_id(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()), _ => self @@ -538,6 +562,9 @@ impl CodexAuth { /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) /// for UI or product decisions based on the user's subscription. pub fn account_plan_type(&self) -> Option { + if matches!(self, Self::Headers(_)) { + return None; + } if let Self::AgentIdentity(auth) = self { return Some(auth.plan_type()); } @@ -564,6 +591,7 @@ impl CodexAuth { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, Self::ApiKey(_) + | Self::Headers(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => return None, @@ -609,6 +637,7 @@ impl CodexAuth { Self::AgentIdentity(auth) => Ok(Some(auth.clone())), Self::ApiKey(_) | Self::ChatgptAuthTokens(_) + | Self::Headers(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => Ok(None), Self::Chatgpt(_) => { @@ -1055,10 +1084,12 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( | (ForcedLoginMethod::Api, AuthMode::BedrockApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Chatgpt, AuthMode::Headers) | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Api, AuthMode::Headers) | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." @@ -1083,7 +1114,9 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { let chatgpt_account_id = match &auth { - CodexAuth::ApiKey(_) | CodexAuth::BedrockApiKey(_) => return Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_) => { + return Ok(()); + } CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => { auth.get_account_id() } @@ -2117,6 +2150,7 @@ impl AuthManager { | (AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => { a.get_current_auth_json() == b.get_current_auth_json() } + (AuthMode::Headers, AuthMode::Headers) => a == b, (AuthMode::AgentIdentity, AuthMode::AgentIdentity) => match (a, b) { (CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => { a.record() == b.record() @@ -2406,6 +2440,7 @@ impl AuthManager { } CodexAuth::ApiKey(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) | CodexAuth::BedrockApiKey(_) => Ok(()), diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index d87c7cc6ad..16477d7827 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,5 +1,6 @@ mod access_token; mod agent_identity; +mod auth_headers; mod bedrock_api_key; pub mod default_client; pub mod error; @@ -11,6 +12,7 @@ mod external_bearer; mod manager; mod revoke; +pub use auth_headers::AuthHeaders; pub use bedrock_api_key::BedrockApiKeyAuth; pub use bedrock_api_key::login_with_bedrock_api_key; pub use error::RefreshTokenFailedError; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index c5a3c1e28b..9b3f82f382 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -25,6 +25,7 @@ pub use success_page::LoginSuccessPageBrand; pub use auth::AgentIdentityAuthPolicy; pub use auth::AuthConfig; pub use auth::AuthDotJson; +pub use auth::AuthHeaders; pub use auth::AuthKeyringBackendKind; pub use auth::AuthManager; pub use auth::AuthManagerConfig; diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 65517cbd43..61cd717d58 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -244,6 +244,7 @@ impl ModelProviderInfo { Some( AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken ) diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index 5949f36bb8..9dbb23ecc1 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -148,6 +148,15 @@ fn test_personal_access_token_uses_chatgpt_codex_base_url() { assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); } +#[test] +fn test_header_auth_uses_chatgpt_codex_base_url() { + let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None) + .to_api_provider(Some(AuthMode::Headers)) + .expect("OpenAI provider should build API provider"); + + assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); +} + #[test] fn test_supports_remote_compaction_for_azure_name() { let provider = ModelProviderInfo { diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 108308c1bf..9fdf937bcb 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -69,6 +69,7 @@ impl AmazonBedrockModelProvider { CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }) diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 5b667e4549..bcd36f1912 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -7,6 +7,7 @@ use codex_agent_identity::authorization_header_for_agent_task; use codex_api::AgentIdentityTelemetry; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use codex_login::AuthHeaders; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuth; @@ -109,6 +110,17 @@ impl AuthProvider for AgentIdentityAuthProvider { } } +#[derive(Clone, Debug)] +struct HeaderAuthProvider { + auth: AuthHeaders, +} + +impl AuthProvider for HeaderAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.extend(self.auth.headers().clone()); + } +} + // Some providers are meant to send no auth headers. Examples include local OSS // providers and custom test providers with `requires_openai_auth = false`. #[derive(Clone, Debug)] @@ -243,6 +255,7 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { CodexAuth::AgentIdentity(auth) => { Arc::new(AgentIdentityAuthProvider { auth: auth.clone() }) } + CodexAuth::Headers(auth) => Arc::new(HeaderAuthProvider { auth: auth.clone() }), CodexAuth::BedrockApiKey(_) => unreachable!("{BEDROCK_API_KEY_UNSUPPORTED_MESSAGE}"), CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) @@ -388,6 +401,21 @@ mod tests { assert!(auth.to_auth_headers().is_empty()); } + #[test] + fn header_auth_adds_predefined_headers() { + let mut expected = HeaderMap::new(); + expected.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer external"), + ); + expected.insert("x-external-auth", HeaderValue::from_static("enabled")); + let auth = CodexAuth::Headers(AuthHeaders::new(expected.clone())); + + let actual = auth_provider_from_auth(&auth).to_auth_headers(); + + assert_eq!(actual, expected); + } + #[test] fn openai_provider_rejects_bedrock_api_key_auth() { let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 5f101e52cd..2b8cb67b97 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -277,6 +277,9 @@ impl ModelProvider for ConfiguredModelProvider { if auth_manager.refresh_failure_for_auth(&auth).is_some() { return None; } + if matches!(auth, CodexAuth::Headers(_)) { + return None; + } Some(auth) }) .map(|auth| match &auth { @@ -286,6 +289,7 @@ impl ModelProvider for ConfiguredModelProvider { } CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => { let email = auth.get_account_email(); diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 87ccbbfeee..65b034cc31 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -59,6 +59,7 @@ impl From for TelemetryAuthMode { AuthMode::ApiKey | AuthMode::BedrockApiKey => Self::ApiKey, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken => Self::Chatgpt, } diff --git a/codex-rs/protocol/src/auth.rs b/codex-rs/protocol/src/auth.rs index 7c506e87eb..a537c0268d 100644 --- a/codex-rs/protocol/src/auth.rs +++ b/codex-rs/protocol/src/auth.rs @@ -15,6 +15,10 @@ pub enum AuthMode { #[serde(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] ChatgptAuthTokens, + /// Codex backend auth supplied as request headers. + #[serde(rename = "headers")] + #[strum(serialize = "headers")] + Headers, /// Programmatic Codex auth backed by a registered Agent Identity. #[serde(rename = "agentIdentity")] #[strum(serialize = "agentIdentity")] @@ -34,7 +38,7 @@ impl AuthMode { pub fn has_chatgpt_account(self) -> bool { match self { Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, - Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false, + Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false, } } @@ -43,6 +47,7 @@ impl AuthMode { match self { Self::Chatgpt | Self::ChatgptAuthTokens + | Self::Headers | Self::AgentIdentity | Self::PersonalAccessToken => true, Self::ApiKey | Self::BedrockApiKey => false, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index aeca17091e..ab29dfd598 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1207,7 +1207,7 @@ pub(crate) fn status_account_display_from_auth_mode( email: None, plan: plan_type.map(plan_type_display_name), }), - Some(AuthMode::BedrockApiKey) => None, + Some(AuthMode::Headers) | Some(AuthMode::BedrockApiKey) => None, None => None, } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index c5c4ef442d..619163bc66 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -952,6 +952,7 @@ impl AuthModeWidget { ApiAuthMode::ApiKey => AuthMode::ApiKey, ApiAuthMode::Chatgpt => AuthMode::Chatgpt, ApiAuthMode::ChatgptAuthTokens => AuthMode::ChatgptAuthTokens, + ApiAuthMode::Headers => AuthMode::Headers, ApiAuthMode::AgentIdentity => AuthMode::AgentIdentity, ApiAuthMode::PersonalAccessToken => AuthMode::PersonalAccessToken, ApiAuthMode::BedrockApiKey => AuthMode::BedrockApiKey, From f8d07b564155a1b79527edde285307e9a1bc434b Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 7 Jul 2026 18:00:35 -0700 Subject: [PATCH 21/53] trace hook command execution (#31501) Adds a single trace span around configured hook command execution so slow installed hooks are visible directly in traces. The span records low-cardinality hook metadata and command outcome without command text, hook input, stdout, or stderr. --- codex-rs/hooks/src/engine/command_runner.rs | 141 ++++++++++++++------ codex-rs/hooks/src/engine/dispatcher.rs | 60 ++++++++- 2 files changed, 159 insertions(+), 42 deletions(-) diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs index 7366d4ec51..9f4c4e6059 100644 --- a/codex-rs/hooks/src/engine/command_runner.rs +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -6,9 +6,18 @@ use std::time::Instant; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::time::timeout; +use tracing::Span; use super::CommandShell; use super::ConfiguredHandler; +use super::dispatcher::hook_event_name_label; +use super::dispatcher::hook_execution_mode_label; +use super::dispatcher::hook_handler_type_label; +use super::dispatcher::hook_scope_label; +use super::dispatcher::hook_source_label; +use super::dispatcher::scope_for_event; +use codex_protocol::protocol::HookExecutionMode; +use codex_protocol::protocol::HookHandlerType; #[derive(Debug)] pub(crate) struct CommandRunResult { @@ -21,9 +30,26 @@ pub(crate) struct CommandRunResult { pub error: Option, } +#[tracing::instrument( + name = "codex.hooks.command", + level = "trace", + skip_all, + fields( + hook.event_name = hook_event_name_label(handler.event_name), + hook.handler_type = hook_handler_type_label(HookHandlerType::Command), + hook.execution_mode = hook_execution_mode_label(HookExecutionMode::Sync), + hook.scope = hook_scope_label(scope_for_event(handler.event_name)), + hook.source = hook_source_label(handler.source), + hook.display_order = handler.display_order, + hook.configured_order = configured_order, + hook.timeout_sec = handler.timeout_sec, + hook.command_outcome = tracing::field::Empty, + ) +)] pub(crate) async fn run_command( shell: &CommandShell, handler: &ConfiguredHandler, + configured_order: usize, input_json: &str, cwd: &Path, ) -> CommandRunResult { @@ -41,15 +67,17 @@ pub(crate) async fn run_command( let mut child = match command.spawn() { Ok(child) => child, Err(err) => { - return CommandRunResult { + return finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(err.to_string()), - }; + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(err.to_string()), + outcome: "spawn_error", + }, + ); } }; @@ -57,46 +85,79 @@ pub(crate) async fn run_command( && let Err(err) = stdin.write_all(input_json.as_bytes()).await { let _ = child.kill().await; - return CommandRunResult { + return finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(format!("failed to write hook stdin: {err}")), - }; + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(format!("failed to write hook stdin: {err}")), + outcome: "stdin_error", + }, + ); } let timeout_duration = Duration::from_secs(handler.timeout_sec); match timeout(timeout_duration, child.wait_with_output()).await { - Ok(Ok(output)) => CommandRunResult { + Ok(Ok(output)) => finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: output.status.code(), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - error: None, - }, - Ok(Err(err)) => CommandRunResult { + started, + CommandRunCompletion { + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + error: None, + outcome: "completed", + }, + ), + Ok(Err(err)) => finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(err.to_string()), - }, - Err(_) => CommandRunResult { + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(err.to_string()), + outcome: "wait_error", + }, + ), + Err(_) => finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(format!("hook timed out after {}s", handler.timeout_sec)), - }, + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(format!("hook timed out after {}s", handler.timeout_sec)), + outcome: "timeout", + }, + ), + } +} + +struct CommandRunCompletion { + exit_code: Option, + stdout: String, + stderr: String, + error: Option, + outcome: &'static str, +} + +fn finish_command_run( + started_at: i64, + started: Instant, + completion: CommandRunCompletion, +) -> CommandRunResult { + Span::current().record("hook.command_outcome", completion.outcome); + CommandRunResult { + started_at, + completed_at: chrono::Utc::now().timestamp(), + duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), + exit_code: completion.exit_code, + stdout: completion.stdout, + stderr: completion.stderr, + error: completion.error, } } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 50822bfc96..ccf5c2c8e6 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -99,7 +99,7 @@ pub(crate) async fn execute_handlers( let input_json = input_json.clone(); let turn_id = turn_id.clone(); pending.push(async move { - let result = run_command(shell, &handler, &input_json, cwd).await; + let result = run_command(shell, &handler, configured_order, &input_json, cwd).await; (configured_order, parse(&handler, result, turn_id)) }); } @@ -139,7 +139,7 @@ pub(crate) fn completed_summary( } } -fn scope_for_event(event_name: HookEventName) -> HookScope { +pub(crate) fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread, HookEventName::PreToolUse @@ -153,12 +153,68 @@ fn scope_for_event(event_name: HookEventName) -> HookScope { } } +pub(crate) fn hook_event_name_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", + HookEventName::Stop => "Stop", + } +} + +pub(crate) fn hook_execution_mode_label(mode: HookExecutionMode) -> &'static str { + match mode { + HookExecutionMode::Sync => "sync", + HookExecutionMode::Async => "async", + } +} + +pub(crate) fn hook_handler_type_label(handler_type: HookHandlerType) -> &'static str { + match handler_type { + HookHandlerType::Command => "command", + HookHandlerType::Prompt => "prompt", + HookHandlerType::Agent => "agent", + } +} + +pub(crate) fn hook_scope_label(scope: HookScope) -> &'static str { + match scope { + HookScope::Thread => "thread", + HookScope::Turn => "turn", + } +} + +pub(crate) fn hook_source_label(source: codex_protocol::protocol::HookSource) -> &'static str { + match source { + codex_protocol::protocol::HookSource::System => "system", + codex_protocol::protocol::HookSource::User => "user", + codex_protocol::protocol::HookSource::Project => "project", + codex_protocol::protocol::HookSource::Mdm => "mdm", + codex_protocol::protocol::HookSource::SessionFlags => "session_flags", + codex_protocol::protocol::HookSource::Plugin => "plugin", + codex_protocol::protocol::HookSource::CloudRequirements => "cloud_requirements", + codex_protocol::protocol::HookSource::CloudManagedConfig => "cloud_managed_config", + codex_protocol::protocol::HookSource::LegacyManagedConfigFile => { + "legacy_managed_config_file" + } + codex_protocol::protocol::HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", + codex_protocol::protocol::HookSource::Unknown => "unknown", + } +} + #[cfg(test)] mod tests { use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; use super::ConfiguredHandler; use super::select_handlers; From aaa30f79c23e1c11a58394ff6edf1617d470c030 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 18:12:49 -0700 Subject: [PATCH 22/53] ci: run V8 source builds on Windows 2025 (#31356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The V8 Windows source jobs need a runner image that can support the later Dev Drive setup. ## What Move the two V8 Windows source-build matrix entries from `windows-2022` to `windows-2025`. Namespace the canary source-build cache by runner image so it cannot restore Windows 2022 native outputs. ## Manual validation - Ran `just fmt`. - Ran `just test-github-scripts` (33 tests). - Parsed GitHub Actions YAML with `yq`. - Ran `git diff --check`. ## Stack - [#31332](https://github.com/openai/codex/pull/31332) — parameterize Cargo target paths - [#31356](https://github.com/openai/codex/pull/31356) — Windows 2025 runner bump - [#31357](https://github.com/openai/codex/pull/31357) — Dev Drive I/O routing --- .github/workflows/v8-canary.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 0a1cba4498..d5f0133528 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -314,9 +314,9 @@ jobs: fail-fast: false matrix: include: - - runner: windows-2022 + - runner: windows-2025 target: x86_64-pc-windows-msvc - - runner: windows-2022 + - runner: windows-2025 target: aarch64-pc-windows-msvc steps: @@ -368,9 +368,9 @@ jobs: path: | ${{ env.CARGO_TARGET_DIR }}/sccache ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release/gn_out - key: rusty-v8-source-${{ matrix.target }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }} + key: rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }} restore-keys: | - rusty-v8-source-${{ matrix.target }}-sandbox- + rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox- - name: Install and start sccache shell: pwsh From f1affbac5e5164b2bae825e9b39e9868bc4e0be2 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 7 Jul 2026 20:55:13 -0700 Subject: [PATCH 23/53] core: support extension-owned turn items (#31283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR adds a `codex-extension-items` crate for extension-owned `TurnItem` schemas, and updates standalone image generation to start using it via `TurnItem::Extension`. This gives us a way to prevent Core from having to be aware of all extension items. App-server still exposes the existing public `ThreadItem::ImageGeneration` shape, now by wrapping the same shared `image_generation::ImageGenerationItem` type. The new `codex-extension-items` crate is necessary because the image gen extension item is used by: - `codex-image-generation-extension`, which produces it. - `codex-tools / core`, which carry it generically. - `codex-protocol`, which serializes it into lifecycle events and rollouts. - `app-server protocol`, which wraps it in public `ThreadItem::ImageGeneration` ``` extension implementation ↓ codex-extension-items ↓ protocol / tools / app-server ``` We keep the hosted Responses API image generation as `TurnItem::ImageGeneration` because core still owns its persistence and legacy fanout. ### Before Standalone image generation is implemented as an extension, but its item representation previously lived in the core protocol. This sets the precedent that core is aware of all extension items, which would be good to avoid. ``` image-gen extension → constructs codex_protocol::ImageGenerationItem → emits ExtensionTurnItem::ImageGeneration → core matches ImageGeneration specially → protocol stores TurnItem::ImageGeneration ``` ### After ``` image-gen extension → constructs extension-owned ImageGenerationItem → emits generic ExtensionItem → core transports/persists it generically → app-server wraps ImageGenerationItem as ThreadItem::ImageGeneration ``` Future extension items can have typed app-server APIs without adding a new `TurnItem` variant, `ExtensionTurnItem` variant, or core emitter match arm. ## What changed - Added `codex-extension-items` with the closed `ExtensionItem` enum and shared `image_generation::ImageGenerationItem` schema. - Added generic `TurnItem::Extension(ExtensionItem)` and `ExtensionTurnItem::Extension { item, legacy_events }` paths. - Updated standalone image generation to emit a typed extension item and provide its existing legacy `ImageGenerationBegin` / `ImageGenerationEnd` events explicitly. - Kept canonical lifecycle ordering: core emits `ItemStarted` / `ItemCompleted` before extension-provided legacy events. ## Follow-up Standalone web search still uses its typed special-case path. Migrating it later would let `ExtensionTurnItem` collapse into a single extension-item struct. --- codex-rs/Cargo.lock | 17 ++ codex-rs/Cargo.toml | 2 + .../analytics/src/analytics_client_tests.rs | 5 +- codex-rs/analytics/src/reducer.rs | 22 +- codex-rs/app-server-protocol/Cargo.toml | 1 + .../schema/typescript/ImageGenerationItem.ts | 6 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/ThreadItem.ts | 4 +- .../src/protocol/thread_history.rs | 71 ++++- .../src/protocol/v2/item.rs | 48 ++-- .../thread_resume_redaction.rs | 7 +- .../tests/suite/v2/imagegen_extension.rs | 11 +- .../tests/suite/v2/thread_resume.rs | 11 +- codex-rs/core/Cargo.toml | 1 + .../src/tools/handlers/extension_tools.rs | 247 ++++++------------ codex-rs/ext/image-generation/Cargo.toml | 1 + codex-rs/ext/image-generation/src/tool.rs | 74 ++++-- codex-rs/ext/items/BUILD.bazel | 6 + codex-rs/ext/items/Cargo.toml | 23 ++ codex-rs/ext/items/src/image_generation.rs | 21 ++ codex-rs/ext/items/src/lib.rs | 51 ++++ codex-rs/ext/items/src/tests.rs | 57 ++++ codex-rs/protocol/Cargo.toml | 1 + codex-rs/protocol/src/items.rs | 11 + codex-rs/protocol/src/legacy_events.rs | 1 + codex-rs/rollout/src/persistence_metrics.rs | 1 + codex-rs/tools/Cargo.toml | 1 + codex-rs/tools/src/tool_call.rs | 17 +- codex-rs/tui/src/app/agent_status_feed.rs | 2 +- codex-rs/tui/src/chatwidget/protocol.rs | 2 +- codex-rs/tui/src/chatwidget/replay.rs | 15 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 5 +- codex-rs/tui/src/thread_transcript.rs | 13 +- 33 files changed, 484 insertions(+), 272 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts create mode 100644 codex-rs/ext/items/BUILD.bazel create mode 100644 codex-rs/ext/items/Cargo.toml create mode 100644 codex-rs/ext/items/src/image_generation.rs create mode 100644 codex-rs/ext/items/src/lib.rs create mode 100644 codex-rs/ext/items/src/tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f6879c58e6..9e250998ff 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2108,6 +2108,7 @@ dependencies = [ "anyhow", "clap", "codex-experimental-api-macros", + "codex-extension-items", "codex-protocol", "codex-shell-command", "codex-utils-absolute-path", @@ -2642,6 +2643,7 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-extension-api", + "codex-extension-items", "codex-features", "codex-feedback", "codex-file-system", @@ -3014,6 +3016,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-extension-items" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "ts-rs", +] + [[package]] name = "codex-external-agent-migration" version = "0.0.0" @@ -3236,6 +3250,7 @@ dependencies = [ "codex-core", "codex-exec-server", "codex-extension-api", + "codex-extension-items", "codex-login", "codex-model-provider", "codex-model-provider-info", @@ -3715,6 +3730,7 @@ dependencies = [ "chrono", "codex-async-utils", "codex-execpolicy", + "codex-extension-items", "codex-network-proxy", "codex-utils-absolute-path", "codex-utils-image", @@ -4072,6 +4088,7 @@ version = "0.0.0" dependencies = [ "codex-code-mode", "codex-connectors", + "codex-extension-items", "codex-features", "codex-file-system", "codex-protocol", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ad08ce8e02..0c1a4f9711 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -54,6 +54,7 @@ members = [ "ext/goal", "ext/guardian", "ext/image-generation", + "ext/items", "ext/memories", "ext/mcp", "ext/skills", @@ -180,6 +181,7 @@ codex-exec-server-protocol = { path = "exec-server-protocol" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } +codex-extension-items = { path = "ext/items" } codex-goal-extension = { path = "ext/goal" } codex-guardian = { path = "ext/guardian" } codex-image-generation-extension = { path = "ext/image-generation" } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0f759fd938..0789c9de48 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -103,6 +103,7 @@ use codex_app_server_protocol::GuardianApprovalReview; use codex_app_server_protocol::GuardianApprovalReviewAction; use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource; +use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::ItemCompletedNotification; @@ -4405,13 +4406,13 @@ async fn turn_event_counts_completed_tool_items() { query: "codex".to_string(), action: None, }, - ThreadItem::ImageGeneration { + ThreadItem::ImageGeneration(ImageGenerationItem { id: "image-1".to_string(), status: "completed".to_string(), revised_prompt: None, result: "ok".to_string(), saved_path: None, - }, + }), ]; for item in completed_tool_items { diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 29f21bac52..fa742b74db 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -403,7 +403,7 @@ impl TurnToolCounts { self.subagent_tool_call += 1; } ThreadItem::WebSearch { .. } => self.web_search += 1, - ThreadItem::ImageGeneration { .. } => self.image_generation += 1, + ThreadItem::ImageGeneration(_) => self.image_generation += 1, ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } | ThreadItem::AgentMessage { .. } @@ -1733,8 +1733,8 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { | ThreadItem::McpToolCall { id, .. } | ThreadItem::DynamicToolCall { id, .. } | ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::WebSearch { id, .. } - | ThreadItem::ImageGeneration { id, .. } => Some(id), + | ThreadItem::WebSearch { id, .. } => Some(id), + ThreadItem::ImageGeneration(item) => Some(&item.id), ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } | ThreadItem::AgentMessage { .. } @@ -2057,18 +2057,12 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, })) } - ThreadItem::ImageGeneration { - id, - status, - revised_prompt, - saved_path, - .. - } => { - let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + ThreadItem::ImageGeneration(item) => { + let (terminal_status, failure_kind) = image_generation_outcome(item.status.as_str()); let base = tool_item_base( thread_id, turn_id, - id.clone(), + item.id.clone(), "image_generation".to_string(), ToolItemOutcome { terminal_status, @@ -2089,8 +2083,8 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { event_type: "codex_image_generation_event", event_params: CodexImageGenerationEventParams { base, - revised_prompt_present: revised_prompt.is_some(), - saved_path_present: saved_path.is_some(), + revised_prompt_present: item.revised_prompt.is_some(), + saved_path_present: item.saved_path.is_some(), }, }, )) diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index dc69e2d4bf..1f0cf450b3 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -16,6 +16,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-experimental-api-macros = { workspace = true } +codex-extension-items = { workspace = true } codex-protocol = { workspace = true } codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts b/codex-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts new file mode 100644 index 0000000000..26cd6285bc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "./AbsolutePathBuf"; + +export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index dcfecf1282..092aa3c5e4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -37,6 +37,7 @@ export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse"; export type { GitSha } from "./GitSha"; export type { ImageDetail } from "./ImageDetail"; +export type { ImageGenerationItem } from "./ImageGenerationItem"; export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index c611ab071c..4cb8330b79 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -1,7 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { ImageGenerationItem } from "../ImageGenerationItem"; import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; @@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 2d9c6b6a1a..ca3849bb38 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -22,6 +22,7 @@ use crate::protocol::v2::TurnItemsView; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; use crate::protocol::v2::WebSearchAction; +use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::MessagePhase; use codex_protocol::protocol::AgentReasoningEvent; @@ -592,7 +593,8 @@ impl ThreadHistoryBuilder { | codex_protocol::items::TurnItem::CommandExecution(_) | codex_protocol::items::TurnItem::DynamicToolCall(_) | codex_protocol::items::TurnItem::CollabAgentToolCall(_) - | codex_protocol::items::TurnItem::SubAgentActivity(_) => true, + | codex_protocol::items::TurnItem::SubAgentActivity(_) + | codex_protocol::items::TurnItem::Extension(_) => true, codex_protocol::items::TurnItem::UserMessage(_) | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::AgentMessage(_) @@ -827,24 +829,24 @@ impl ThreadHistoryBuilder { } fn handle_image_generation_begin(&mut self, payload: &ImageGenerationBeginEvent) { - let item = ThreadItem::ImageGeneration { + let item = ThreadItem::ImageGeneration(ImageGenerationItem { id: payload.call_id.clone(), status: String::new(), revised_prompt: None, result: String::new(), saved_path: None, - }; + }); self.upsert_item_in_current_turn(item); } fn handle_image_generation_end(&mut self, payload: &ImageGenerationEndEvent) { - let item = ThreadItem::ImageGeneration { + let item = ThreadItem::ImageGeneration(ImageGenerationItem { id: payload.call_id.clone(), status: payload.status.clone(), revised_prompt: payload.revised_prompt.clone(), result: payload.result.clone(), saved_path: payload.saved_path.clone(), - }; + }); self.upsert_item_in_current_turn(item); } @@ -1548,6 +1550,7 @@ impl From<&PendingTurn> for Turn { mod tests { use super::*; use crate::protocol::v2::CommandExecutionSource; + use codex_extension_items::ExtensionItem as CoreExtensionItem; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::items::CommandExecutionItem as CoreCommandExecutionItem; @@ -1846,6 +1849,60 @@ mod tests { ); } + #[test] + fn rebuilds_extension_image_generation_item_from_persisted_completion() { + let turn_id = "turn-1"; + let thread_id = ThreadId::new(); + let saved_path = test_path_buf("/tmp/image-1.png").abs(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: turn_id.to_string(), + item: CoreTurnItem::Extension(CoreExtensionItem::ImageGeneration( + ImageGenerationItem { + id: "image-1".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(saved_path.clone()), + }, + )), + completed_at_ms: 1_000, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + + let turns = build_turns_from_rollout_items(&items); + + assert_eq!( + turns[0].items, + vec![ThreadItem::ImageGeneration(ImageGenerationItem { + id: "image-1".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(saved_path), + })] + ); + } + #[test] fn rebuilds_command_execution_item_from_persisted_completion() { let turn_id = "turn-1"; @@ -2058,13 +2115,13 @@ mod tests { text_elements: Vec::new(), }], }, - ThreadItem::ImageGeneration { + ThreadItem::ImageGeneration(ImageGenerationItem { id: "ig_123".into(), status: "completed".into(), revised_prompt: Some("final prompt".into()), result: "Zm9v".into(), saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), - }, + }), ], } ); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index cd04752449..2fea01da6a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -11,6 +11,8 @@ use super::shared::v2_enum_from_core; use crate::protocol::item_builders::command_actions_for_path_uri; use crate::protocol::item_builders::convert_patch_changes; use codex_experimental_api_macros::ExperimentalApi; +use codex_extension_items::ExtensionItem; +pub use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; @@ -246,7 +248,10 @@ pub enum ThreadItem { #[ts(rename_all = "camelCase")] /// EXPERIMENTAL - proposed plan item content. The completed plan item is /// authoritative and may not match the concatenation of `PlanDelta` text. - Plan { id: String, text: String }, + Plan { + id: String, + text: String, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] Reasoning { @@ -373,26 +378,24 @@ pub enum ThreadItem { #[ts(type = "number")] duration_ms: u64, }, + ImageGeneration(ImageGenerationItem), #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - ImageGeneration { + EnteredReviewMode { id: String, - status: String, - revised_prompt: Option, - result: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - saved_path: Option, + review: String, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - EnteredReviewMode { id: String, review: String }, + ExitedReviewMode { + id: String, + review: String, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - ExitedReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ContextCompaction { id: String }, + ContextCompaction { + id: String, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -432,10 +435,10 @@ impl ThreadItem { | ThreadItem::WebSearch { id, .. } | ThreadItem::ImageView { id, .. } | ThreadItem::Sleep { id, .. } - | ThreadItem::ImageGeneration { id, .. } | ThreadItem::EnteredReviewMode { id, .. } | ThreadItem::ExitedReviewMode { id, .. } | ThreadItem::ContextCompaction { id, .. } => id, + ThreadItem::ImageGeneration(item) => &item.id, } } } @@ -931,13 +934,18 @@ impl From for ThreadItem { id: sleep.id, duration_ms: sleep.duration_ms, }, - CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { - id: image.id, - status: image.status, - revised_prompt: image.revised_prompt, - result: image.result, - saved_path: image.saved_path, + CoreTurnItem::Extension(extension) => match extension { + ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item), }, + CoreTurnItem::ImageGeneration(image) => { + ThreadItem::ImageGeneration(ImageGenerationItem { + id: image.id, + status: image.status, + revised_prompt: image.revised_prompt, + result: image.result, + saved_path: image.saved_path, + }) + } CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { id: file_change.id, changes: convert_patch_changes(&file_change.changes), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 90d25ff593..598e0bde08 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -32,7 +32,7 @@ pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) { } true } - ThreadItem::ImageGeneration { .. } => false, + ThreadItem::ImageGeneration(_) => false, _ => true, }); } @@ -52,6 +52,7 @@ fn redacted_mcp_tool_call_result() -> McpToolCallResult { #[cfg(test)] mod tests { use super::*; + use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::McpToolCallAppContext; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallStatus; @@ -100,13 +101,13 @@ mod tests { error: None, duration_ms: Some(8), }, - ThreadItem::ImageGeneration { + ThreadItem::ImageGeneration(ImageGenerationItem { id: "ig-1".to_string(), status: "completed".to_string(), revised_prompt: Some("revised".to_string()), result: "base64-result".to_string(), saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), - }, + }), ]); redact_thread_resume_payloads(&mut thread.turns); diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 8ce38bd729..0c3799ca11 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -107,13 +108,13 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul ) .await??; - let ThreadItem::ImageGeneration { + let ThreadItem::ImageGeneration(ImageGenerationItem { status, revised_prompt, result, saved_path: Some(saved_path), .. - } = completed.item + }) = completed.item else { panic!("expected completed image generation item with saved path"); }; @@ -205,13 +206,13 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> .await??; assert_eq!( completed.item, - ThreadItem::ImageGeneration { + ThreadItem::ImageGeneration(ImageGenerationItem { id: call_id.to_string(), status: "failed".to_string(), revised_prompt: Some("paint a blue whale".to_string()), result: String::new(), saved_path: None, - } + }) ); timeout( @@ -537,7 +538,7 @@ async fn wait_for_image_generation_completed( .params .context("item/completed notification should include params")?, )?; - if matches!(&completed.item, ThreadItem::ImageGeneration { .. }) { + if matches!(&completed.item, ThreadItem::ImageGeneration(_)) { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index f8fcd84486..a9c3a1f700 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -889,7 +889,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( !remote_turn .items .iter() - .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), + .any(|item| matches!(item, ThreadItem::ImageGeneration(_))), "remote resume should drop image generation items for {client_name}" ); } @@ -943,12 +943,9 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( assert!( normal_turn.items.iter().any(|item| matches!( item, - ThreadItem::ImageGeneration { - result, - revised_prompt, - .. - } if result == "base64-image-result" - && revised_prompt.as_deref() == Some("secret revised prompt") + ThreadItem::ImageGeneration(item) + if item.result == "base64-image-result" + && item.revised_prompt.as_deref() == Some("secret revised prompt") )), "normal resume should keep image generation items" ); diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index c617684f78..5dc2586b6a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -134,6 +134,7 @@ openssl-sys = { workspace = true, features = ["vendored"] } codex-shell-escalation = { workspace = true } [dev-dependencies] +codex-extension-items = { workspace = true } assert_cmd = { workspace = true } assert_matches = { workspace = true } codex-image-generation-extension = { workspace = true } diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 1aa7754a18..716f4d3dd8 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use std::sync::Weak; use codex_protocol::items::TurnItem; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; use codex_tools::ToolCall as ExtensionToolCall; @@ -16,7 +18,6 @@ use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::TurnItemContributorPolicy; -use crate::stream_events_utils::apply_turn_item_contributors; use crate::stream_events_utils::finalize_turn_item; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -69,10 +70,14 @@ struct CoreTurnItemEmitter { turn: Weak, } -fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { - match item { - ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), - ExtensionTurnItem::ImageGeneration(item) => TurnItem::ImageGeneration(item), +async fn emit_legacy_events(session: &Session, turn: &TurnContext, legacy_events: Vec) { + for msg in legacy_events { + session + .send_event_raw(Event { + id: turn.sub_id.clone(), + msg, + }) + .await; } } @@ -82,9 +87,15 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - session - .emit_turn_item_started(turn.as_ref(), &extension_turn_item(item)) - .await; + let (item, legacy_events) = match item { + ExtensionTurnItem::WebSearch(item) => (TurnItem::WebSearch(item), Vec::new()), + ExtensionTurnItem::Extension { + item, + legacy_events, + } => (TurnItem::Extension(item), legacy_events), + }; + session.emit_turn_item_started(turn.as_ref(), &item).await; + emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) } @@ -93,17 +104,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = match item { - ExtensionTurnItem::ImageGeneration(item) => { - let mut item = TurnItem::ImageGeneration(item); - apply_turn_item_contributors( - session.as_ref(), - turn.extension_data.as_ref(), - &mut item, - ) - .await; - item - } + let (item, legacy_events) = match item { + ExtensionTurnItem::Extension { + item, + legacy_events, + } => (TurnItem::Extension(item), legacy_events), ExtensionTurnItem::WebSearch(item) => { let mut item = TurnItem::WebSearch(item); finalize_turn_item( @@ -115,10 +120,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter { == codex_protocol::config_types::ModeKind::Plan, ) .await; - item + (item, Vec::new()) } }; session.emit_turn_item_completed(turn.as_ref(), item).await; + emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) } } @@ -173,14 +179,16 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { mod tests { use std::sync::Arc; - use codex_extension_api::ExtensionData; - use codex_extension_api::TurnItemContributor; + use codex_extension_items::ExtensionItem; + use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ImageGenerationBeginEvent; + use codex_protocol::protocol::ImageGenerationEndEvent; use codex_tools::ExtensionTurnItem; use codex_utils_absolute_path::test_support::PathExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -447,147 +455,64 @@ mod tests { assert_eq!(end.action, expected.action); } - struct ImageGenerationExtensionExecutor; - - #[derive(Debug)] - struct ExtensionTurnItemContributorRan; - - struct RecordExtensionTurnItemContributor; - - impl TurnItemContributor for RecordExtensionTurnItemContributor { - fn contribute<'a>( - &'a self, - _thread_store: &'a ExtensionData, - turn_store: &'a ExtensionData, - _item: &'a mut TurnItem, - ) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> { - Box::pin(async move { - turn_store.insert(ExtensionTurnItemContributorRan); - Ok(()) - }) - } - } - - #[tokio::test] - async fn extension_completion_runs_turn_item_contributors() { - let (mut session, turn) = crate::session::tests::make_session_and_context().await; - let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); - builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor)); - session.services.extensions = Arc::new(builder.build()); - let session = Arc::new(session); - let turn = Arc::new(turn); - let emitter = CoreTurnItemEmitter { - session: Arc::downgrade(&session), - turn: Arc::downgrade(&turn), - }; - - codex_tools::TurnItemEmitter::emit_completed( - &emitter, - ExtensionTurnItem::WebSearch(WebSearchItem { - id: "search-1".to_string(), - query: "contributors".to_string(), - action: WebSearchAction::Other, - }), - ) - .await; - - assert!( - turn.extension_data - .get::() - .is_some() - ); - } - - impl codex_extension_api::ToolExecutor for ImageGenerationExtensionExecutor { - fn tool_name(&self) -> codex_tools::ToolName { - codex_tools::ToolName::namespaced("image_gen", "imagegen") - } - - fn spec(&self) -> codex_tools::ToolSpec { - codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool { - name: "imagegen".to_string(), - description: "Generates an image.".to_string(), - strict: false, - parameters: codex_tools::JsonSchema::default(), - output_schema: None, - defer_loading: None, - }) - } - - fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> { - Box::pin(self.handle_call(call)) - } - } - - impl ImageGenerationExtensionExecutor { - async fn handle_call( - &self, - call: codex_tools::ToolCall, - ) -> Result, codex_tools::FunctionCallError> { - call.turn_item_emitter - .emit_started(ExtensionTurnItem::ImageGeneration( - codex_protocol::items::ImageGenerationItem { - id: call.call_id.clone(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - }, - )) - .await; - call.turn_item_emitter - .emit_completed(ExtensionTurnItem::ImageGeneration( - codex_protocol::items::ImageGenerationItem { - id: call.call_id, - status: "completed".to_string(), - revised_prompt: Some("A tiny blue square".to_string()), - result: "cG5n".to_string(), - saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()), - }, - )) - .await; - Ok( - Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) - as Box, - ) - } - } - #[tokio::test] async fn image_generation_publication_preserves_extension_saved_path() { let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; - let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor)); let expected_path = test_path_buf("/tmp/extension-claimed.png").abs(); let default_path = crate::stream_events_utils::image_generation_artifact_path( &turn.config.codex_home, &session.thread_id.to_string(), "call-image", ); - let step_context = StepContext::for_test(Arc::clone(&turn)); - let invocation = ToolInvocation { - session, - step_context, - turn, - cancellation_token: tokio_util::sync::CancellationToken::new(), - tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), - call_id: "call-image".to_string(), - tool_name: codex_tools::ToolName::namespaced("image_gen", "imagegen"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: "{}".to_string(), - }, + let emitter = CoreTurnItemEmitter { + session: Arc::downgrade(&session), + turn: Arc::downgrade(&turn), }; - - crate::tools::registry::ToolExecutor::handle(&handler, invocation) - .await - .expect("extension call should succeed"); + let expected_started_item = ExtensionItem::ImageGeneration(ImageGenerationItem { + id: "call-image".to_string(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }); + let expected_completed_item = ExtensionItem::ImageGeneration(ImageGenerationItem { + id: "call-image".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(expected_path.clone()), + }); + codex_tools::TurnItemEmitter::emit_started( + &emitter, + ExtensionTurnItem::Extension { + item: expected_started_item.clone(), + legacy_events: vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { + call_id: "call-image".to_string(), + })], + }, + ) + .await; + codex_tools::TurnItemEmitter::emit_completed( + &emitter, + ExtensionTurnItem::Extension { + item: expected_completed_item.clone(), + legacy_events: vec![EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: "call-image".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(expected_path.clone()), + })], + }, + ) + .await; let started = rx.recv().await.expect("item started event"); let EventMsg::ItemStarted(started) = started.msg else { panic!("expected item started event"); }; - let TurnItem::ImageGeneration(started_item) = started.item else { - panic!("expected image generation item"); + let TurnItem::Extension(started_item) = started.item else { + panic!("expected extension item"); }; let begin = rx.recv().await.expect("legacy image start event"); assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_))); @@ -595,32 +520,14 @@ mod tests { let EventMsg::ItemCompleted(completed) = completed.msg else { panic!("expected item completed event"); }; - let TurnItem::ImageGeneration(completed_item) = completed.item else { - panic!("expected image generation item"); + let TurnItem::Extension(completed_item) = completed.item else { + panic!("expected extension item"); }; let end = rx.recv().await.expect("legacy image end event"); assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_))); - assert_eq!( - started_item, - codex_protocol::items::ImageGenerationItem { - id: "call-image".to_string(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - } - ); - assert_eq!( - completed_item, - codex_protocol::items::ImageGenerationItem { - id: "call-image".to_string(), - status: "completed".to_string(), - revised_prompt: Some("A tiny blue square".to_string()), - result: "cG5n".to_string(), - saved_path: Some(expected_path.clone()), - } - ); + assert_eq!(started_item, expected_started_item); + assert_eq!(completed_item, expected_completed_item); assert!(!default_path.exists()); } } diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index c5139ac12b..ad10b2b0a6 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -18,6 +18,7 @@ codex-api = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-extension-api = { workspace = true } +codex-extension-items = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 3bd4dc5348..06a4351a42 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -23,7 +23,8 @@ use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema; -use codex_protocol::items::ImageGenerationItem; +use codex_extension_items::ExtensionItem; +use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; @@ -31,6 +32,9 @@ use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ImageGenerationBeginEvent; +use codex_protocol::protocol::ImageGenerationEndEvent; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; @@ -86,6 +90,23 @@ struct ImagegenArgs { num_last_images_to_include: Option, } +fn legacy_end_event(item: &ImageGenerationItem) -> EventMsg { + EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: item.id.clone(), + status: item.status.clone(), + revised_prompt: item.revised_prompt.clone(), + result: item.result.clone(), + saved_path: item.saved_path.clone(), + }) +} + +fn extension_turn_item(item: ImageGenerationItem, legacy_event: EventMsg) -> ExtensionTurnItem { + ExtensionTurnItem::Extension { + item: ExtensionItem::ImageGeneration(item), + legacy_events: vec![legacy_event], + } +} + impl ToolExecutor for ImageGenerationTool { /// Keeps the tool in the existing image-generation Responses namespace. fn tool_name(&self) -> ToolName { @@ -115,13 +136,18 @@ impl ImageGenerationTool { request_for_call_args(&args, call.conversation_history.items(), &call.environments) .await?; call.turn_item_emitter - .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { - id: call.call_id.clone(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - })) + .emit_started(extension_turn_item( + ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }, + EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { + call_id: call.call_id.clone(), + }), + )) .await; let result = match request { ImageRequest::Generate(request) => self.backend.generate(request).await, @@ -139,14 +165,16 @@ impl ImageGenerationTool { let result = match result { Ok(result) => result, Err(message) => { + let item = ImageGenerationItem { + id: call.call_id.clone(), + status: "failed".to_string(), + revised_prompt: Some(args.prompt), + result: String::new(), + saved_path: None, + }; + let legacy_event = legacy_end_event(&item); call.turn_item_emitter - .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { - id: call.call_id.clone(), - status: "failed".to_string(), - revised_prompt: Some(args.prompt.clone()), - result: String::new(), - saved_path: None, - })) + .emit_completed(extension_turn_item(item, legacy_event)) .await; return Err(FunctionCallError::RespondToModel(message)); } @@ -176,14 +204,16 @@ impl ImageGenerationTool { }, None => None, }; + let item = ImageGenerationItem { + id: call.call_id.clone(), + status: "completed".to_string(), + revised_prompt: Some(args.prompt), + result: result.clone(), + saved_path: saved_path.clone(), + }; + let legacy_event = legacy_end_event(&item); call.turn_item_emitter - .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { - id: call.call_id.clone(), - status: "completed".to_string(), - revised_prompt: Some(args.prompt), - result: result.clone(), - saved_path: saved_path.clone(), - })) + .emit_completed(extension_turn_item(item, legacy_event)) .await; let output_hint = saved_path.as_ref().and_then(|output_path| { let output_dir = output_path.parent()?; diff --git a/codex-rs/ext/items/BUILD.bazel b/codex-rs/ext/items/BUILD.bazel new file mode 100644 index 0000000000..9bc0c2b673 --- /dev/null +++ b/codex-rs/ext/items/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "items", + crate_name = "codex_extension_items", +) diff --git a/codex-rs/ext/items/Cargo.toml b/codex-rs/ext/items/Cargo.toml new file mode 100644 index 0000000000..fdf848eddf --- /dev/null +++ b/codex-rs/ext/items/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-extension-items" +version.workspace = true + +[lib] +name = "codex_extension_items" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +ts-rs = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } diff --git a/codex-rs/ext/items/src/image_generation.rs b/codex-rs/ext/items/src/image_generation.rs new file mode 100644 index 0000000000..7b740421f9 --- /dev/null +++ b/codex-rs/ext/items/src/image_generation.rs @@ -0,0 +1,21 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +// Standalone image-generation item owned by the image extension. This is also +// the field-level representation exposed by app-server; core and rollout +// persistence only carry it inside an ExtensionItem envelope. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct ImageGenerationItem { + pub id: String, + pub status: String, + pub revised_prompt: Option, + pub result: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub saved_path: Option, +} diff --git a/codex-rs/ext/items/src/lib.rs b/codex-rs/ext/items/src/lib.rs new file mode 100644 index 0000000000..27eeb9abb0 --- /dev/null +++ b/codex-rs/ext/items/src/lib.rs @@ -0,0 +1,51 @@ +//! Typed display items owned by Codex extensions. +//! +//! This crate intentionally sits below `codex-protocol` so core can carry +//! extension items without owning each extension's display schema. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +pub mod image_generation; + +/// Canonical extension-owned turn item carried through core lifecycle events. +/// +/// The item is serialized as a flattened, namespaced envelope: +/// +/// ```json +/// { +/// "kind": "image_gen.generation", +/// "id": "call-id", +/// "status": "completed", +/// "revisedPrompt": "A blue square", +/// "result": "cG5n", +/// "savedPath": "/tmp/image.png" +/// } +/// ``` +/// +/// `kind` values follow `.`. Adding a variant +/// also requires app-server to add its typed public wrapper. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(tag = "kind")] +#[ts(tag = "kind")] +pub enum ExtensionItem { + #[serde(rename = "image_gen.generation")] + #[ts(rename = "image_gen.generation")] + ImageGeneration(image_generation::ImageGenerationItem), +} + +impl ExtensionItem { + /// Returns the stable item identifier without exposing variant fields to + /// core or rollout persistence. + pub fn id(&self) -> &str { + match self { + Self::ImageGeneration(item) => &item.id, + } + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/ext/items/src/tests.rs b/codex-rs/ext/items/src/tests.rs new file mode 100644 index 0000000000..88575b4bfe --- /dev/null +++ b/codex-rs/ext/items/src/tests.rs @@ -0,0 +1,57 @@ +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::ExtensionItem; +use super::image_generation::ImageGenerationItem; + +fn completed_image_generation_item() -> ExtensionItem { + ExtensionItem::ImageGeneration(ImageGenerationItem { + id: "image-1".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A blue square".to_string()), + result: "cG5n".to_string(), + saved_path: None, + }) +} + +#[test] +fn image_generation_item_preserves_stable_wire_shape() { + let item = completed_image_generation_item(); + let value = serde_json::to_value(&item).expect("serialize extension item"); + + assert_eq!( + value, + json!({ + "kind": "image_gen.generation", + "id": "image-1", + "status": "completed", + "revisedPrompt": "A blue square", + "result": "cG5n", + }) + ); + assert_eq!( + serde_json::from_value::(value).expect("deserialize extension item"), + item + ); +} + +#[test] +fn unknown_extension_kind_is_rejected() { + let value = json!({ + "kind": "image_gen.unknown", + "id": "image-1", + }); + + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn malformed_known_extension_payload_is_rejected() { + let value = json!({ + "kind": "image_gen.generation", + "id": "image-1", + "status": "completed", + }); + + assert!(serde_json::from_value::(value).is_err()); +} diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 5d1ff57f55..23d065d25d 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -17,6 +17,7 @@ chardetng = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-async-utils = { workspace = true } codex-execpolicy = { workspace = true } +codex-extension-items = { workspace = true } codex-network-proxy = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-image = { workspace = true } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 2c9d073f0d..2c29306cda 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -20,6 +20,7 @@ use crate::protocol::SubAgentActivityKind; use crate::user_input::ByteRange; use crate::user_input::TextElement; use crate::user_input::UserInput; +use codex_extension_items::ExtensionItem; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use quick_xml::de::from_str as from_xml_str; @@ -49,6 +50,15 @@ pub enum TurnItem { WebSearch(WebSearchItem), ImageView(ImageViewItem), Sleep(SleepItem), + /// Item whose schema and lifecycle details are owned by an extension. + /// + /// Standalone image generation uses this path. App-server wraps the same + /// typed item in its public image-generation variant. + Extension(ExtensionItem), + /// Hosted Responses API image-generation item handled directly by core. + /// + /// This remains separate from [`Self::Extension`] because core still owns + /// hosted image persistence and legacy-event fanout. ImageGeneration(ImageGenerationItem), FileChange(FileChangeItem), McpToolCall(McpToolCallItem), @@ -605,6 +615,7 @@ impl TurnItem { TurnItem::WebSearch(item) => item.id.clone(), TurnItem::ImageView(item) => item.id.clone(), TurnItem::Sleep(item) => item.id.clone(), + TurnItem::Extension(item) => item.id().to_string(), TurnItem::ImageGeneration(item) => item.id.clone(), TurnItem::FileChange(item) => item.id.clone(), TurnItem::McpToolCall(item) => item.id.clone(), diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs index c12a2747a2..12c2b98d1e 100644 --- a/codex-rs/protocol/src/legacy_events.rs +++ b/codex-rs/protocol/src/legacy_events.rs @@ -487,6 +487,7 @@ impl TurnItem { })] } TurnItem::Sleep(_) => Vec::new(), + TurnItem::Extension(_) => Vec::new(), TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()], TurnItem::FileChange(item) => item .as_legacy_end_event(String::new()) diff --git a/codex-rs/rollout/src/persistence_metrics.rs b/codex-rs/rollout/src/persistence_metrics.rs index 8c2a7e18d2..e8510a0d93 100644 --- a/codex-rs/rollout/src/persistence_metrics.rs +++ b/codex-rs/rollout/src/persistence_metrics.rs @@ -254,6 +254,7 @@ fn turn_item_type(item: &TurnItem) -> &'static str { TurnItem::WebSearch(_) => "web_search", TurnItem::ImageView(_) => "image_view", TurnItem::Sleep(_) => "sleep", + TurnItem::Extension(_) => "extension", TurnItem::ImageGeneration(_) => "image_generation", TurnItem::FileChange(_) => "file_change", TurnItem::McpToolCall(_) => "mcp_tool_call", diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index b5f838ca36..76a5de5115 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -12,6 +12,7 @@ codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-features = { workspace = true } codex-file-system = { workspace = true } +codex-extension-items = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-output-truncation = { workspace = true } diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 0bcee56223..8810ea4376 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,11 +1,12 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_extension_items::ExtensionItem; use codex_file_system::ExecutorFileSystem; use codex_file_system::FileSystemSandboxContext; -use codex_protocol::items::ImageGenerationItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use std::future::Future; @@ -34,10 +35,20 @@ impl ConversationHistory { pub type TurnItemEmissionFuture<'a> = Pin + Send + 'a>>; /// Visible turn items that an extension may publish into the host lifecycle. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub enum ExtensionTurnItem { WebSearch(WebSearchItem), - ImageGeneration(ImageGenerationItem), + /// Canonical extension item plus compatibility events derived by its owner. + /// + /// Core intentionally does not inspect extension-owned payloads, so it + /// cannot derive their legacy fanout. It emits the canonical lifecycle + /// event first, then these extension-provided events. Core also skips + /// global turn-item contributors here so extensions cannot mutate items + /// owned by other extensions. + Extension { + item: ExtensionItem, + legacy_events: Vec, + }, } /// Host-provided capability for extension tools to emit visible turn items. diff --git a/codex-rs/tui/src/app/agent_status_feed.rs b/codex-rs/tui/src/app/agent_status_feed.rs index eb14551d5a..91f8784f6d 100644 --- a/codex-rs/tui/src/app/agent_status_feed.rs +++ b/codex-rs/tui/src/app/agent_status_feed.rs @@ -185,7 +185,7 @@ fn activity_summary(item: &ThreadItem) -> Option { let path = path.render_for_ui(); return bounded_summary(&format!("Viewed {path}")); } - ThreadItem::ImageGeneration { .. } => return Some("Generated an image".to_string()), + ThreadItem::ImageGeneration(_) => return Some("Generated an image".to_string()), ThreadItem::EnteredReviewMode { .. } => return Some("Entered review mode".to_string()), ThreadItem::ExitedReviewMode { .. } => return Some("Exited review mode".to_string()), ThreadItem::ContextCompaction { .. } => return Some("Compacted context".to_string()), diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 0c5b0ed3ee..df03fb1c74 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -293,7 +293,7 @@ impl ChatWidget { ThreadItem::WebSearch { id, .. } => { self.on_web_search_begin(id); } - ThreadItem::ImageGeneration { .. } => { + ThreadItem::ImageGeneration(_) => { self.on_image_generation_begin(); } ThreadItem::CollabAgentToolCall { diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 3ab5b3486b..8b454fa0c4 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -150,14 +150,13 @@ impl ChatWidget { ThreadItem::ImageView { id: _, path } => { self.on_view_image_tool_call(path); } - ThreadItem::ImageGeneration { - id, - status, - revised_prompt, - saved_path, - .. - } => { - self.on_image_generation_end(id, status, revised_prompt, saved_path); + ThreadItem::ImageGeneration(item) => { + self.on_image_generation_end( + item.id, + item.status, + item.revised_prompt, + item.saved_path, + ); } ThreadItem::EnteredReviewMode { review, .. } => { if from_replay { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 5f890ab000..f0d808ad3d 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1,4 +1,5 @@ use super::*; +use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::PluginAvailability; use pretty_assertions::assert_eq; @@ -707,13 +708,13 @@ pub(super) fn handle_image_generation_end( thread_id: thread_id(chat), turn_id: "turn-1".to_string(), completed_at_ms: 0, - item: AppServerThreadItem::ImageGeneration { + item: AppServerThreadItem::ImageGeneration(ImageGenerationItem { id: call_id.into(), status: status.into(), revised_prompt, result: String::new(), saved_path, - }, + }), }), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index cdffaab27c..2e43f40d95 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -206,14 +206,17 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option { let path = path.render_for_ui(); vec![format!("image: {path}").dim().into()] } - ThreadItem::ImageGeneration { - status, saved_path, .. - } => { - let saved = saved_path + ThreadItem::ImageGeneration(item) => { + let saved = item + .saved_path .as_ref() .map(|path| format!(" · {}", path.as_path().display())) .unwrap_or_default(); - vec![format!("image generation: {status}{saved}").dim().into()] + vec![ + format!("image generation: {}{saved}", item.status) + .dim() + .into(), + ] } ThreadItem::EnteredReviewMode { review, .. } => { vec![vec!["review started: ".dim(), review.clone().into()].into()] From a52b35fcf679a00a76cb50b7f94a643ccd38b52d Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 8 Jul 2026 13:49:05 +0100 Subject: [PATCH 24/53] fs: support pruning hidden directories during walks (#31570) Why Filtering hidden directories after a walk is too late: their descendants consume traversal limits, and canonical directory deduplication can let a hidden path claim a target before a visible symlink reaches it. What this changes - Add an optional pruneHiddenDirectories walk option. It defaults to false and is omitted from the wire when disabled. - Return hidden directory entries, but do not traverse them or add their canonical identities to the visited set. - Cover the visible-symlink-to-hidden-directory case through both local and remote filesystem implementations. This is the small filesystem prerequisite for #31566. Skill-specific behavior remains in that PR. --- .../core-skills/src/loader/environment.rs | 1 + .../exec-server/tests/file_system/shared.rs | 5 ++ .../exec-server/tests/file_system_unix.rs | 64 +++++++++++++++++++ codex-rs/file-system/src/lib.rs | 6 ++ 4 files changed, 76 insertions(+) diff --git a/codex-rs/core-skills/src/loader/environment.rs b/codex-rs/core-skills/src/loader/environment.rs index da525d3362..8d400d7cdc 100644 --- a/codex-rs/core-skills/src/loader/environment.rs +++ b/codex-rs/core-skills/src/loader/environment.rs @@ -160,6 +160,7 @@ pub async fn load_environment_skills_from_root( max_directories: MAX_SKILLS_DIRS_PER_ROOT, max_entries: MAX_SKILLS_ENTRIES_PER_ROOT, follow_directory_symlinks: true, + prune_hidden_directories: false, }, /*sandbox*/ None, ) diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index 261c6b07d0..236a9623ee 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -402,6 +402,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 10, max_entries: 10, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -447,6 +448,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 10, max_entries: 10, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -469,6 +471,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 1, max_entries: 10, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -491,6 +494,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 10, max_entries: 1, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -535,6 +539,7 @@ async fn file_system_walk_honors_read_sandbox( max_directories: 2, max_entries: 2, follow_directory_symlinks: false, + prune_hidden_directories: false, }, Some(&sandbox), ) diff --git a/codex-rs/exec-server/tests/file_system_unix.rs b/codex-rs/exec-server/tests/file_system_unix.rs index f658045441..c3fc680787 100644 --- a/codex-rs/exec-server/tests/file_system_unix.rs +++ b/codex-rs/exec-server/tests/file_system_unix.rs @@ -299,6 +299,7 @@ async fn file_system_walk_handles_directory_symlinks( max_directories: 4, max_entries: 8, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -321,6 +322,7 @@ async fn file_system_walk_handles_directory_symlinks( max_directories: 4, max_entries: 8, follow_directory_symlinks: true, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -351,6 +353,68 @@ async fn file_system_walk_handles_directory_symlinks( Ok(()) } +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_walk_prunes_hidden_directories_without_claiming_visible_aliases( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let root = tmp.path().join("root"); + let hidden = root.join(".hidden"); + let hidden_nested = hidden.join("nested"); + let visible = root.join("visible"); + std::fs::create_dir_all(&hidden_nested)?; + std::fs::write(hidden_nested.join("note.txt"), "visible through alias")?; + symlink(&hidden, &visible)?; + + let outcome = file_system + .walk( + &PathUri::from_host_native_path(&root)?, + WalkOptions { + max_depth: 3, + max_directories: 3, + max_entries: 6, + follow_directory_symlinks: true, + prune_hidden_directories: true, + }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + + assert_eq!( + outcome, + WalkOutcome { + entries: vec![ + WalkEntry { + path: PathUri::from_host_native_path(hidden)?, + kind: WalkEntryKind::Directory, + }, + WalkEntry { + path: PathUri::from_host_native_path(&visible)?, + kind: WalkEntryKind::Directory, + }, + WalkEntry { + path: PathUri::from_host_native_path(visible.join("nested"))?, + kind: WalkEntryKind::Directory, + }, + WalkEntry { + path: PathUri::from_host_native_path(visible.join("nested/note.txt"))?, + kind: WalkEntryKind::File, + }, + ], + errors: Vec::new(), + truncated: false, + } + ); + + Ok(()) +} + #[test_case(FileSystemImplementation::Local ; "local")] #[test_case(FileSystemImplementation::Remote ; "remote")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 737412f463..2ef3bcf710 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -81,6 +81,9 @@ pub struct WalkOptions { pub max_entries: usize, /// Whether directory symlinks should be followed. pub follow_directory_symlinks: bool, + /// Whether directories whose names start with `.` should be returned but not traversed. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub prune_hidden_directories: bool, } /// Type of a filesystem entry returned by a walk. @@ -463,6 +466,9 @@ async fn walk_via_directory_reads( }); if kind == WalkEntryKind::Directory && depth < options.max_depth { + if options.prune_hidden_directories && entry.file_name.starts_with('.') { + continue; + } let directory_identity = if options.follow_directory_symlinks { match file_system.canonicalize(&path, sandbox).await { Ok(path) => path, From 6849549b49446fc3db89ecc13d6226ab971bec79 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 8 Jul 2026 08:57:25 -0400 Subject: [PATCH 25/53] Align empty branch list message with search (#31465) ## Summary Selection rows reserve their first two columns for the selection cursor and render into the menu surface's left gutter. The empty-state message reused that shifted row area even though it has no cursor prefix, so `no matches` appeared two columns to the left of the search field. This renders empty lists at the inset list origin while preserving the cursor gutter for non-empty rows. --- .../src/bottom_pane/list_selection_view.rs | 29 ++++++++++++++++++- ...ests__list_selection_empty_searchable.snap | 11 +++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 4455a6b670..75d11ae91f 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -1269,7 +1269,11 @@ impl Renderable for ListSelectionView { // -- List rows -- if list_area.height > 0 { let render_area = Rect { - x: list_area.x.saturating_sub(2), + x: if rows.is_empty() { + list_area.x + } else { + list_area.x.saturating_sub(2) + }, y: list_area.y, width: effective_rows_width.max(1), height: list_area.height, @@ -1715,6 +1719,29 @@ mod tests { ); } + #[test] + fn empty_searchable_list_aligns_message_with_search() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + title: Some("Select a base branch".to_string()), + footer_hint: Some(standard_popup_hint_line()), + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, /*width*/ 48) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + assert_snapshot!("list_selection_empty_searchable", rendered); + } + #[test] fn paste_appends_to_search_query_and_filters_items() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap new file mode 100644 index 0000000000..a69e588ec5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: rendered +--- + + Select a base branch + + Type to search branches + no matches + + Press enter to confirm or esc to go back From 8dfd3975f5a7c3847dde13f80a949e8fab0d3bae Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 8 Jul 2026 15:59:04 +0100 Subject: [PATCH 26/53] Stabilize encrypted MAv2 spawn request test (#31586) ## Why The encrypted MAv2 spawn test often reads the parent follow-up request before the child has sent its first request. The response mock records candidate requests before applying its specific matcher, so the test can see an empty `agent_message` list even though delivery happens a moment later. ## What changed Wait for the recorded child request that contains `agent_message`, using the same short bounded polling pattern already used in this test module. The exact encrypted payload and communication-log assertions stay unchanged. This is test-only: it does not add product delays, loosen the assertion, or change multi-agent behavior. No follow-up is expected. --- .../tests/suite/subagent_notifications.rs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 7424d3f360..fdaed25623 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1120,10 +1120,22 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result test.submit_turn(TURN_1_PROMPT).await?; - let child_request = wait_for_requests(&child_request_log) - .await? - .pop() - .expect("child request"); + // The response mock records candidate requests before its request matcher runs, so wait for + // the child request instead of assuming the latest recorded request is already it. + let deadline = Instant::now() + Duration::from_secs(2); + let child_request = loop { + if let Some(request) = child_request_log + .requests() + .into_iter() + .find(|request| !request.inputs_of_type("agent_message").is_empty()) + { + break request; + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for child agent message request"); + } + sleep(Duration::from_millis(10)).await; + }; assert_eq!( strip_metadata_from_json(Value::Array(child_request.inputs_of_type("agent_message"))), Value::Array(vec![json!({ From f17a57b7d5a1d1002bca37a57228d8214b862f00 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 8 Jul 2026 15:59:36 +0100 Subject: [PATCH 27/53] Stabilize remote compaction parity against dynamic skill catalogs (#31585) ## Why The remote compaction parity test compares legacy and v2 sessions created with separate temporary homes. Those sessions can discover different model-visible skill catalogs, so the request comparison can fail even when the compaction and service-tier behavior matches. This is the most frequent retry-saved full-CI failure in the recent JUnit history. ## What changed Normalize only the contents of `` before comparing the captured requests. The opening and closing tags remain in the comparison, so the test still catches a missing or misplaced skills block. The service-tier, compacted input, follow-up request, and replacement-history assertions are unchanged. A focused normalizer test covers the new behavior. ## Scope This is test-only. It does not change runtime compaction or skill behavior. Exact skill-catalog rendering remains covered by the dedicated skills tests. --- .../core/tests/suite/compact_remote_parity.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 6f56ce016d..d4042a8c2e 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -941,6 +941,19 @@ fn normalize_string(value: &str) -> String { normalize_tmp_prefix_before_marker(&mut text, "/skills/"); normalize_tmp_prefix_before_marker(&mut text, "\\skills\\"); + let skills_open_tag = ""; + let skills_close_tag = ""; + let mut search_start = 0; + while let Some(relative_start) = text[search_start..].find(skills_open_tag) { + let body_start = search_start + relative_start + skills_open_tag.len(); + let Some(relative_end) = text[body_start..].find(skills_close_tag) else { + break; + }; + let body_end = body_start + relative_end; + text.replace_range(body_start..body_end, "\n...\n"); + search_start = body_start + "\n...\n".len() + skills_close_tag.len(); + } + let mut search_start = 0; let wall_time_prefix = "Wall time: "; let wall_time_suffix = " seconds"; @@ -964,6 +977,19 @@ fn normalize_string(value: &str) -> String { text } +#[test] +fn normalize_string_rewrites_dynamic_skill_instructions() { + let text = normalize_string( + "before\n\n## Skills\n- demo: Dynamic description\n\ + \nafter", + ); + + assert_eq!( + text, + "before\n\n...\n\nafter" + ); +} + fn is_uuid_like(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 36 From 1ee0e9a9495ca4643c0cbeea32df15619c5754bd Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 8 Jul 2026 11:06:45 -0400 Subject: [PATCH 28/53] Log plugin install failure subtypes (#31518) ## Summary - classify plugin store and remote bundle I/O failures by privacy-safe operation context - include the normalized `sub_error_type` in structured plugin-install failure warnings - leave analytics events and schemas unchanged ## Why Top-level error types such as `store_io` collapse multiple filesystem operations, making logs harder to diagnose. The subtype identifies the failed operation without logging paths or new exception details. ## Impact Plugin-install warning logs gain an optional normalized subtype. Analytics payloads, app-server APIs, and stored data are unchanged. ## Validation - `just fmt` - `just test -p codex-core-plugins -p codex-app-server` (1,235 passed; 14 failed and 6 timed out because local `sandbox-exec` was denied, `test_stdio_server` was unavailable, or user-level skill fixtures were loaded) --- .../src/request_processors/plugins.rs | 8 +++++++ codex-rs/core-plugins/src/manager.rs | 16 ++++++++++++++ codex-rs/core-plugins/src/remote_bundle.rs | 21 +++++++++++++++++++ codex-rs/core-plugins/src/store.rs | 11 ++++++++++ 4 files changed, 56 insertions(+) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index f0d7558526..ed8495d602 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1548,6 +1548,7 @@ impl PluginRequestProcessor { &remote_marketplace_name, /*plugin_id*/ None, error_type, + /*sub_error_type*/ None, err.to_string(), ); remote_plugin_catalog_error_to_jsonrpc( @@ -1591,11 +1592,13 @@ impl PluginRequestProcessor { ) .map_err(|err| { let error_type = remote_plugin_bundle_install_error_type(&err); + let sub_error_type = err.sub_error_type(); self.track_plugin_install_failed_for_remote_plugin( &remote_plugin_id, &actual_remote_marketplace_name, Some(&resolved_plugin_id), error_type, + sub_error_type, err.to_string(), ); remote_plugin_bundle_install_error_to_jsonrpc(err) @@ -1608,11 +1611,13 @@ impl PluginRequestProcessor { .await .map_err(|err| { let error_type = remote_plugin_bundle_install_error_type(&err); + let sub_error_type = err.sub_error_type(); self.track_plugin_install_failed_for_remote_plugin( &remote_plugin_id, &actual_remote_marketplace_name, Some(&resolved_plugin_id), error_type, + sub_error_type, err.to_string(), ); remote_plugin_bundle_install_error_to_jsonrpc(err) @@ -1635,6 +1640,7 @@ impl PluginRequestProcessor { &actual_remote_marketplace_name, Some(&result.plugin_id), error_type, + /*sub_error_type*/ None, err.to_string(), ); remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin") @@ -1732,12 +1738,14 @@ impl PluginRequestProcessor { marketplace_name: &str, plugin_id: Option<&PluginId>, error_type: &'static str, + sub_error_type: Option, error_message: String, ) { tracing::warn!( remote_plugin_id = %remote_plugin_id, marketplace_name = %marketplace_name, error_type = %error_type, + sub_error_type = sub_error_type.as_deref(), error = %error_message, "remote plugin install failed" ); diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 4d215b61d4..6fac0fafe9 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1283,6 +1283,7 @@ impl PluginsManager { self.track_plugin_install_failed( &plugin_id, plugin_install_error_type(&err), + plugin_install_sub_error_type(&err), err.to_string(), ); Err(err) @@ -1345,6 +1346,7 @@ impl PluginsManager { self.track_plugin_install_failed( &resolved.plugin_id, plugin_install_error_type(&err), + plugin_install_sub_error_type(&err), err.to_string(), ); return Err(err); @@ -1356,6 +1358,7 @@ impl PluginsManager { self.track_plugin_install_failed( &plugin_id, plugin_install_error_type(&err), + plugin_install_sub_error_type(&err), err.to_string(), ); Err(err) @@ -1383,6 +1386,7 @@ impl PluginsManager { self.track_plugin_install_failed( &plugin_id, marketplace_error_type(err), + /*sub_error_type*/ None, err.to_string(), ); } else { @@ -1398,11 +1402,13 @@ impl PluginsManager { &self, plugin_id: &PluginId, error_type: &'static str, + sub_error_type: Option, error_message: String, ) { tracing::warn!( plugin_id = %plugin_id.as_key(), error_type = %error_type, + sub_error_type = sub_error_type.as_deref(), error = %error_message, "plugin install failed" ); @@ -2674,6 +2680,16 @@ fn plugin_install_error_type(err: &PluginInstallError) -> &'static str { } } +fn plugin_install_sub_error_type(err: &PluginInstallError) -> Option { + match err { + PluginInstallError::Store(err) => err.sub_error_type(), + PluginInstallError::Marketplace(_) + | PluginInstallError::Remote(_) + | PluginInstallError::Config(_) + | PluginInstallError::Join(_) => None, + } +} + fn marketplace_error_type(err: &MarketplaceError) -> &'static str { match err { MarketplaceError::Io { .. } => "marketplace_io", diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 66592dc363..7a302f2f36 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -4,6 +4,7 @@ use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::store::PluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; +use crate::store::error_context_sub_error_type; use crate::store::validate_plugin_version_segment; use codex_login::default_client::build_reqwest_client; use codex_plugin::PluginId; @@ -132,6 +133,26 @@ impl RemotePluginBundleInstallError { fn io(context: &'static str, source: io::Error) -> Self { Self::Io { context, source } } + + pub fn sub_error_type(&self) -> Option { + match self { + Self::Io { context, .. } => Some(error_context_sub_error_type(context)), + Self::Store(err) => err.sub_error_type(), + Self::MissingReleaseVersion { .. } + | Self::InvalidReleaseVersion { .. } + | Self::MissingBundleDownloadUrl { .. } + | Self::InvalidBundleDownloadUrl { .. } + | Self::UnsupportedBundleDownloadUrlScheme { .. } + | Self::InvalidPluginId { .. } + | Self::DownloadRequest { .. } + | Self::DownloadStatus { .. } + | Self::DownloadBody { .. } + | Self::DownloadTooLarge { .. } + | Self::UnsupportedBundleDownloadFinalUrl { .. } + | Self::ExtractedBundleTooLarge { .. } + | Self::InvalidBundle(_) => None, + } + } } pub fn validate_remote_plugin_bundle( diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index bb4929dedb..819f9731be 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -365,6 +365,17 @@ impl PluginStoreError { fn io(context: &'static str, source: io::Error) -> Self { Self::Io { context, source } } + + pub(crate) fn sub_error_type(&self) -> Option { + match self { + Self::Io { context, .. } => Some(error_context_sub_error_type(context)), + Self::Invalid(_) => None, + } + } +} + +pub(crate) fn error_context_sub_error_type(context: &str) -> String { + context.to_ascii_lowercase().replace(' ', "_") } pub fn plugin_version_for_source(source_path: &Path) -> Result { From 0bbea86a6aae37b1f243676db4248000f04ad111 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 8 Jul 2026 16:10:28 +0100 Subject: [PATCH 29/53] Stabilize shared rollout budget test (#31587) ## Why `subagent_usage_draws_from_the_shared_budget` intermittently fails even when the shared-budget behavior is correct. `ResponseMock` records a request before the custom Wiremock predicate is checked, so the follow-up mock can also contain unrelated requests. In a [recent Windows ARM64 run](https://github.com/openai/codex/actions/runs/28916285431), `single_request()` saw all seven requests from the scenario. ## What changed Select the request containing the follow-up user prompt before making assertions. The test still requires exactly one matching follow-up request and still checks that the root sees 50 tokens remaining after the child uses its share. This is test-only. Shared-budget behavior and the common response-mock helper are unchanged. --- codex-rs/core/tests/suite/rollout_budget.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 9485523659..ba199653e4 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -195,9 +195,21 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { .await; test.submit_turn(FOLLOW_UP_PROMPT).await?; - let request = follow_up.single_request(); + let requests = follow_up + .requests() + .into_iter() + .filter(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text == FOLLOW_UP_PROMPT) + }) + .collect::>(); + let [request] = requests.as_slice() else { + anyhow::bail!("expected 1 follow-up request, got {}", requests.len()); + }; assert_eq!( - rollout_budget_texts(&request).last(), + rollout_budget_texts(request).last(), Some(&rollout_budget_message(/*remaining_tokens*/ 50)) ); From e212cc95b0faf340474b821cee431bddbf2003e6 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 8 Jul 2026 12:17:58 -0400 Subject: [PATCH 30/53] Detect Codex installs managed by pnpm (#31503) ## Why The Codex JavaScript shim currently distinguishes npm and Bun installs, but a global pnpm install falls back to npm. That causes the native CLI, `codex doctor`, and update flows to report or run npm commands even though pnpm owns the installation. pnpm also allows its global package and bin directories to differ, so `PNPM_HOME` does not reliably identify the package owner. ## What changed - detect pnpm-managed installs by finding pnpm's `node_modules/.modules.yaml` metadata from the launched JavaScript entrypoint - pass a mutually exclusive `CODEX_MANAGED_BY_PNPM` marker into the native CLI - represent pnpm in install context, doctor output, version checks, and TUI update actions without changing the existing public `InstallContext::from_exe` signature - recommend `pnpm add -g @openai/codex` for pnpm-managed installs and snapshot the rendered TUI update notice Validated with the 9-test install-context suite, the focused pnpm TUI snapshot test, and Node's syntax check for the launcher. Closes https://github.com/openai/codex/issues/10294. --- codex-cli/bin/codex.js | 55 ++++++++++-- codex-rs/cli/src/doctor.rs | 8 ++ codex-rs/cli/src/doctor/output.rs | 2 +- codex-rs/cli/src/doctor/output/detail.rs | 5 +- codex-rs/cli/src/doctor/runtime.rs | 1 + ..._doctor_human_report_environment_rows.snap | 2 +- codex-rs/cli/src/doctor/updates.rs | 9 ++ codex-rs/install-context/src/lib.rs | 85 ++++++++++--------- ...pdate_available_history_cell_snapshot.snap | 11 +++ codex-rs/tui/src/history_cell/tests.rs | 9 ++ codex-rs/tui/src/update_action.rs | 11 +++ codex-rs/tui/src/updates.rs | 4 +- 12 files changed, 152 insertions(+), 50 deletions(-) create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 5cc5194183..a307e38610 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -11,6 +11,7 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); +const codexPackageRoot = realpathSync(path.join(__dirname, "..")); const PLATFORM_PACKAGE_BY_TARGET = { "x86_64-unknown-linux-musl": "@openai/codex-linux-x64", @@ -98,7 +99,9 @@ function findCodexExecutable() { const updateCommand = packageManager === "bun" ? "bun install -g @openai/codex@latest" - : "npm install -g @openai/codex@latest"; + : packageManager === "pnpm" + ? "pnpm add -g @openai/codex@latest" + : "npm install -g @openai/codex@latest"; throw new Error( `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`, ); @@ -112,11 +115,47 @@ const binaryPath = findCodexExecutable(); // and guarantees that when either the child terminates or the parent // receives a fatal signal, both processes exit in a predictable manner. +function isPnpmOwnedCodexInstall(nodeModulesDir) { + if (!existsSync(path.join(nodeModulesDir, ".modules.yaml"))) { + return false; + } + + try { + return ( + realpathSync(path.join(nodeModulesDir, "@openai", "codex")) === + codexPackageRoot + ); + } catch { + return false; + } +} + /** * Use heuristics to detect the package manager that was used to install Codex * in order to give the user a hint about how to update it. */ function detectPackageManager() { + // pnpm's owning node_modules directory can be several parents above the + // package in isolated global layouts. Search ancestors of both the canonical + // package root and lexical entrypoint because pnpm may link either path. + const entrypointDir = path.dirname(path.resolve(process.argv[1])); + for (const startDir of new Set([codexPackageRoot, entrypointDir])) { + const filesystemRoot = path.parse(startDir).root; + for ( + let currentDir = startDir; + currentDir !== filesystemRoot; + currentDir = path.dirname(currentDir) + ) { + if (isPnpmOwnedCodexInstall(path.join(currentDir, "node_modules"))) { + return "pnpm"; + } + } + + if (isPnpmOwnedCodexInstall(path.join(filesystemRoot, "node_modules"))) { + return "pnpm"; + } + } + const userAgent = process.env.npm_config_user_agent || ""; if (/\bbun\//.test(userAgent)) { return "bun"; @@ -137,15 +176,21 @@ function detectPackageManager() { return userAgent ? "npm" : null; } +const packageManager = detectPackageManager(); const packageManagerEnvVar = - detectPackageManager() === "bun" + packageManager === "bun" ? "CODEX_MANAGED_BY_BUN" - : "CODEX_MANAGED_BY_NPM"; + : packageManager === "pnpm" + ? "CODEX_MANAGED_BY_PNPM" + : "CODEX_MANAGED_BY_NPM"; const env = { ...process.env, - [packageManagerEnvVar]: "1", - CODEX_MANAGED_PACKAGE_ROOT: realpathSync(path.join(__dirname, "..")), + CODEX_MANAGED_PACKAGE_ROOT: codexPackageRoot, }; +delete env.CODEX_MANAGED_BY_NPM; +delete env.CODEX_MANAGED_BY_BUN; +delete env.CODEX_MANAGED_BY_PNPM; +env[packageManagerEnvVar] = "1"; const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index cfb9316e91..2e9c0094c4 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -797,6 +797,10 @@ fn installation_check(show_details: bool) -> DoctorCheck { "managed by bun: {}", env::var_os("CODEX_MANAGED_BY_BUN").is_some() )); + details.push(format!( + "managed by pnpm: {}", + env::var_os("CODEX_MANAGED_BY_PNPM").is_some() + )); push_env_path_detail( &mut details, "managed package root", @@ -885,6 +889,7 @@ fn doctor_managed_by_npm(current_exe: Option<&Path>) -> bool { fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { if env::var_os("CODEX_MANAGED_BY_NPM").is_none() && env::var_os("CODEX_MANAGED_BY_BUN").is_none() + && env::var_os("CODEX_MANAGED_BY_PNPM").is_none() { return false; } @@ -937,6 +942,9 @@ fn describe_install_context(context: &InstallContext) -> String { InstallMethod::Bun => { describe_method_with_package_layout("bun", context.package_layout.as_ref()) } + InstallMethod::Pnpm => { + describe_method_with_package_layout("pnpm", context.package_layout.as_ref()) + } InstallMethod::Brew => { describe_method_with_package_layout("brew", context.package_layout.as_ref()) } diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index 81f9231482..4f0380a583 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -1260,7 +1260,7 @@ Environment LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent - managed by npm: no · bun: no · package root — + managed by npm: no · bun: no · pnpm: no · package root — ✓ search search is OK (bundled) ✓ git git version 2.54.0 selected git /usr/bin/git diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs index 05e7af404e..0ee5add05e 100644 --- a/codex-rs/cli/src/doctor/output/detail.rs +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -211,13 +211,15 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec< let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); + let managed_by_pnpm = value(parsed, "managed by pnpm").unwrap_or("false"); let package_root = value(parsed, "managed package root").unwrap_or("not set"); out.push(HumanDetail::Row { label: "managed by".to_string(), value: format!( - "npm: {} · bun: {} · package root {}", + "npm: {} · bun: {} · pnpm: {} · package root {}", yes_no(managed_by_npm), yes_no(managed_by_bun), + yes_no(managed_by_pnpm), if is_falsy(package_root) { "—".to_string() } else { @@ -263,6 +265,7 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec< "install context", "managed by npm", "managed by bun", + "managed by pnpm", "managed package root", "PATH codex entries", ], diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs index 14afa70e4c..d805c7c6b2 100644 --- a/codex-rs/cli/src/doctor/runtime.rs +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -121,6 +121,7 @@ fn install_method_name(context: &InstallContext) -> &'static str { InstallMethod::Standalone { .. } => "standalone", InstallMethod::Npm => "npm", InstallMethod::Bun => "bun", + InstallMethod::Pnpm => "pnpm", InstallMethod::Brew => "brew", InstallMethod::Other => "local build", } diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap index b3c8d1ee78..2944c6c659 100644 --- a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -21,7 +21,7 @@ Environment LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent - managed by npm: no · bun: no · package root — + managed by npm: no · bun: no · pnpm: no · package root — ✓ search search is OK (bundled) ✓ git git version 2.54.0 selected git /usr/bin/git diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs index 246eac2b39..b13de06d14 100644 --- a/codex-rs/cli/src/doctor/updates.rs +++ b/codex-rs/cli/src/doctor/updates.rs @@ -133,6 +133,7 @@ fn update_action_label(context: &InstallContext) -> &'static str { match &context.method { InstallMethod::Npm => "npm install -g @openai/codex", InstallMethod::Bun => "bun install -g @openai/codex", + InstallMethod::Pnpm => "pnpm add -g @openai/codex", InstallMethod::Brew => "brew upgrade --cask codex", InstallMethod::Standalone { .. } => "standalone installer", InstallMethod::Other => "manual or unknown", @@ -144,6 +145,7 @@ fn fetch_latest_version(context: &InstallContext) -> Result { InstallMethod::Brew => fetch_homebrew_cask_version(), InstallMethod::Npm | InstallMethod::Bun + | InstallMethod::Pnpm | InstallMethod::Standalone { .. } | InstallMethod::Other => fetch_latest_github_release_version(), } @@ -223,6 +225,13 @@ mod tests { }), "npm install -g @openai/codex" ); + assert_eq!( + update_action_label(&InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + }), + "pnpm add -g @openai/codex" + ); assert_eq!( update_action_label(&InstallContext { method: InstallMethod::Other, diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 63694c5d62..61dabb0e02 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -56,6 +56,8 @@ pub enum InstallMethod { Npm, /// A Codex binary launched through the bun-managed `codex.js` shim. Bun, + /// A Codex binary launched through the pnpm-managed `codex.js` shim. + Pnpm, /// A Codex binary that appears to come from a Homebrew install prefix. Brew, /// Any other execution environment. @@ -69,15 +71,13 @@ impl InstallContext { pub fn from_exe( is_macos: bool, current_exe: Option<&Path>, - managed_by_npm: bool, - managed_by_bun: bool, + method_override: Option, ) -> Self { let codex_home = codex_utils_home_dir::find_codex_home().ok(); Self::from_exe_with_codex_home( is_macos, current_exe, - managed_by_npm, - managed_by_bun, + method_override, codex_home.as_deref(), ) } @@ -85,15 +85,12 @@ impl InstallContext { fn from_exe_with_codex_home( is_macos: bool, current_exe: Option<&Path>, - managed_by_npm: bool, - managed_by_bun: bool, + method_override: Option, codex_home: Option<&Path>, ) -> Self { let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); - let method = if managed_by_npm { - InstallMethod::Npm - } else if managed_by_bun { - InstallMethod::Bun + let method = if let Some(method) = method_override { + method } else if let Some(exe_path) = current_exe { install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos) } else { @@ -109,13 +106,19 @@ impl InstallContext { pub fn current() -> &'static Self { INSTALL_CONTEXT.get_or_init(|| { let current_exe = std::env::current_exe().ok(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + let method_override = if std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some() { + Some(InstallMethod::Pnpm) + } else if std::env::var_os("CODEX_MANAGED_BY_NPM").is_some() { + Some(InstallMethod::Npm) + } else if std::env::var_os("CODEX_MANAGED_BY_BUN").is_some() { + Some(InstallMethod::Bun) + } else { + None + }; Self::from_exe( cfg!(target_os = "macos"), current_exe.as_deref(), - managed_by_npm, - managed_by_bun, + method_override, ) }) } @@ -308,8 +311,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -343,8 +345,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!(context.rg_command(), default_rg_command()); @@ -386,8 +387,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!( @@ -450,8 +450,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -496,12 +495,10 @@ mod tests { fs::write(path_dir.join(default_rg_command()), "")?; let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; - let context = InstallContext::from_exe_with_codex_home( + let context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false, - /*codex_home*/ None, + /*method_override*/ Some(InstallMethod::Npm), ); assert_eq!(context.method, InstallMethod::Npm); assert!(context.package_layout.is_some()); @@ -526,8 +523,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -550,8 +546,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -560,13 +555,24 @@ mod tests { } #[test] - fn npm_and_bun_take_precedence() { - let npm_context = InstallContext::from_exe_with_codex_home( + fn package_manager_method_overrides_take_precedence() { + let pnpm_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false, - /*codex_home*/ None, + /*method_override*/ Some(InstallMethod::Pnpm), + ); + assert_eq!( + pnpm_context, + InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + } + ); + + let npm_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Npm), ); assert_eq!( npm_context, @@ -576,12 +582,10 @@ mod tests { } ); - let bun_context = InstallContext::from_exe_with_codex_home( + let bun_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_by_npm*/ false, - /*managed_by_bun*/ true, - /*codex_home*/ None, + /*method_override*/ Some(InstallMethod::Bun), ); assert_eq!( bun_context, @@ -597,8 +601,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ true, /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!( diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000..a9dd9cac78 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run pnpm add -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 42dadb8535..f5a3a2efa2 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1137,6 +1137,15 @@ fn standalone_windows_update_available_history_cell_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn pnpm_update_available_history_cell_snapshot() { + let cell = + UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::PnpmGlobalLatest)); + let rendered = render_lines(&cell.display_lines(/*width*/ 110)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[test] fn web_search_history_cell_without_detail_snapshot() { let cell = new_web_search_call("call-1".to_string(), String::new(), WebSearchAction::Other); diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 420562f13b..0fe4408444 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -12,6 +12,8 @@ pub enum UpdateAction { NpmGlobalLatest, /// Update via `bun install -g @openai/codex@latest`. BunGlobalLatest, + /// Update via `pnpm add -g @openai/codex@latest`. + PnpmGlobalLatest, /// Update via `brew upgrade codex`. BrewUpgrade, /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh`. @@ -26,6 +28,7 @@ impl UpdateAction { match &context.method { InstallMethod::Npm => Some(UpdateAction::NpmGlobalLatest), InstallMethod::Bun => Some(UpdateAction::BunGlobalLatest), + InstallMethod::Pnpm => Some(UpdateAction::PnpmGlobalLatest), InstallMethod::Brew => Some(UpdateAction::BrewUpgrade), InstallMethod::Standalone { platform, .. } => Some(match platform { StandalonePlatform::Unix => UpdateAction::StandaloneUnix, @@ -40,6 +43,7 @@ impl UpdateAction { match self { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), + UpdateAction::PnpmGlobalLatest => ("pnpm", &["add", "-g", "@openai/codex"]), UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), UpdateAction::StandaloneUnix => ( "sh", @@ -106,6 +110,13 @@ mod tests { }), Some(UpdateAction::BunGlobalLatest) ); + assert_eq!( + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + }), + Some(UpdateAction::PnpmGlobalLatest) + ); assert_eq!( UpdateAction::from_install_context(&InstallContext { method: InstallMethod::Brew, diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 65a2317560..64e7f76344 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -79,7 +79,9 @@ async fn check_for_update(version_file: &Path, action: Option) -> .await?; version } - Some(UpdateAction::NpmGlobalLatest) | Some(UpdateAction::BunGlobalLatest) => { + Some(UpdateAction::NpmGlobalLatest) + | Some(UpdateAction::BunGlobalLatest) + | Some(UpdateAction::PnpmGlobalLatest) => { let latest_version = fetch_latest_github_release_version().await?; let package_info = create_client() .get(npm_registry::PACKAGE_URL) From a219b6fdb4e9f9655968adf20984916abc8b2290 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 8 Jul 2026 09:40:00 -0700 Subject: [PATCH 31/53] core: migrate standalone web search to extension-owned turn items (#31525) ## Description This PR migrates standalone web search onto the extension-owned turn-item path introduced in #31283. Standalone web search now emits `ExtensionItem::WebSearch` through generic `TurnItem::Extension`, while app-server still exposes the existing typed `ThreadItem::WebSearch` JSON shape. Hosted Responses API web search stays on core-owned `TurnItem::WebSearch`. ## What changed - Added `web_search::WebSearchItem` and `WebSearchAction` to `codex-extension-items` under the stable `web.search` kind. - Collapsed `ExtensionTurnItem` to generic `{ item, legacy_events }` now that no typed extension special cases remain. - Kept the existing `WebSearchBegin` / `WebSearchEnd` compatibility events and canonical-first ordering. - Updated app-server projection/history and generated TypeScript; the app-server JSON schema is unchanged. --- codex-rs/Cargo.lock | 1 + .../analytics/src/analytics_client_tests.rs | 5 +- codex-rs/analytics/src/reducer.rs | 16 +-- .../schema/typescript/WebSearchItem.ts | 6 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/ThreadItem.ts | 4 +- .../src/protocol/thread_history.rs | 25 ++-- .../src/protocol/v2/item.rs | 65 ++++------- .../src/protocol/v2/tests.rs | 28 +++-- .../app-server/tests/suite/v2/web_search.rs | 17 +-- .../src/tools/handlers/extension_tools.rs | 107 ++++++------------ .../src/event_processor_with_human_output.rs | 8 +- .../src/event_processor_with_jsonl_output.rs | 12 +- .../tests/event_processor_with_json_output.rs | 13 ++- codex-rs/ext/image-generation/src/tool.rs | 2 +- codex-rs/ext/items/src/lib.rs | 5 + codex-rs/ext/items/src/tests.rs | 33 ++++++ codex-rs/ext/items/src/web_search.rs | 39 +++++++ codex-rs/ext/web-search/Cargo.toml | 1 + codex-rs/ext/web-search/src/tool.rs | 59 ++++++++-- codex-rs/protocol/src/items.rs | 8 +- codex-rs/tools/src/tool_call.rs | 10 +- codex-rs/tui/src/app/agent_status_feed.rs | 4 +- codex-rs/tui/src/chatwidget/protocol.rs | 4 +- codex-rs/tui/src/chatwidget/replay.rs | 11 +- codex-rs/tui/src/thread_transcript.rs | 4 +- 26 files changed, 279 insertions(+), 209 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts create mode 100644 codex-rs/ext/items/src/web_search.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9e250998ff..b80e8bc9d0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4471,6 +4471,7 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", + "codex-extension-items", "codex-login", "codex-model-provider", "codex-model-provider-info", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0789c9de48..36916534c6 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -140,6 +140,7 @@ use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WebSearchItem; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; @@ -4401,11 +4402,11 @@ async fn turn_event_counts_completed_tool_items() { agent_thread_id: "thread-child".to_string(), agent_path: "/root/child".to_string(), }, - ThreadItem::WebSearch { + ThreadItem::WebSearch(WebSearchItem { id: "web-1".to_string(), query: "codex".to_string(), action: None, - }, + }), ThreadItem::ImageGeneration(ImageGenerationItem { id: "image-1".to_string(), status: "completed".to_string(), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index fa742b74db..79649a553b 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -402,7 +402,7 @@ impl TurnToolCounts { ThreadItem::CollabAgentToolCall { .. } | ThreadItem::SubAgentActivity { .. } => { self.subagent_tool_call += 1; } - ThreadItem::WebSearch { .. } => self.web_search += 1, + ThreadItem::WebSearch(_) => self.web_search += 1, ThreadItem::ImageGeneration(_) => self.image_generation += 1, ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } @@ -1732,8 +1732,8 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { | ThreadItem::FileChange { id, .. } | ThreadItem::McpToolCall { id, .. } | ThreadItem::DynamicToolCall { id, .. } - | ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::WebSearch { id, .. } => Some(id), + | ThreadItem::CollabAgentToolCall { id, .. } => Some(id), + ThreadItem::WebSearch(item) => Some(&item.id), ThreadItem::ImageGeneration(item) => Some(&item.id), ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } @@ -2027,11 +2027,11 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, )) } - ThreadItem::WebSearch { id, query, action } => { + ThreadItem::WebSearch(item) => { let base = tool_item_base( thread_id, turn_id, - id.clone(), + item.id.clone(), "web_search".to_string(), ToolItemOutcome { terminal_status: ToolItemTerminalStatus::Completed, @@ -2051,9 +2051,9 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { event_type: "codex_web_search_event", event_params: CodexWebSearchEventParams { base, - web_search_action: action.as_ref().map(web_search_action_kind), - query_present: !query.trim().is_empty(), - query_count: web_search_query_count(query, action.as_ref()), + web_search_action: item.action.as_ref().map(web_search_action_kind), + query_present: !item.query.trim().is_empty(), + query_count: web_search_query_count(&item.query, item.action.as_ref()), }, })) } diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts new file mode 100644 index 0000000000..bc1e6d54a0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WebSearchAction } from "./v2/WebSearchAction"; + +export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 092aa3c5e4..6237fb4448 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -82,6 +82,7 @@ export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; export type { WebSearchAction } from "./WebSearchAction"; export type { WebSearchContextSize } from "./WebSearchContextSize"; +export type { WebSearchItem } from "./WebSearchItem"; export type { WebSearchLocation } from "./WebSearchLocation"; export type { WebSearchMode } from "./WebSearchMode"; export type { WebSearchToolConfig } from "./WebSearchToolConfig"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 4cb8330b79..a445385581 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -5,6 +5,7 @@ import type { ImageGenerationItem } from "../ImageGenerationItem"; import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { WebSearchItem } from "../WebSearchItem"; import type { JsonValue } from "../serde_json/JsonValue"; import type { CollabAgentState } from "./CollabAgentState"; import type { CollabAgentTool } from "./CollabAgentTool"; @@ -24,7 +25,6 @@ import type { MemoryCitation } from "./MemoryCitation"; import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { SubAgentActivityKind } from "./SubAgentActivityKind"; import type { UserInput } from "./UserInput"; -import type { WebSearchAction } from "./WebSearchAction"; export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** @@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index ca3849bb38..08fb4ecd5b 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -21,7 +21,10 @@ use crate::protocol::v2::TurnError; use crate::protocol::v2::TurnItemsView; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; +#[cfg(test)] use crate::protocol::v2::WebSearchAction; +use crate::protocol::v2::WebSearchItem; +use crate::protocol::v2::web_search_action_from_core; use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::MessagePhase; @@ -613,20 +616,20 @@ impl ThreadHistoryBuilder { } fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) { - let item = ThreadItem::WebSearch { + let item = ThreadItem::WebSearch(WebSearchItem { id: payload.call_id.clone(), query: String::new(), action: None, - }; + }); self.upsert_item_in_current_turn(item); } fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) { - let item = ThreadItem::WebSearch { + let item = ThreadItem::WebSearch(WebSearchItem { id: payload.call_id.clone(), query: payload.query.clone(), - action: Some(WebSearchAction::from(payload.action.clone())), - }; + action: Some(web_search_action_from_core(payload.action.clone())), + }); self.upsert_item_in_current_turn(item); } @@ -2549,14 +2552,14 @@ mod tests { assert_eq!(turns[0].items.len(), 4); assert_eq!( turns[0].items[1], - ThreadItem::WebSearch { + ThreadItem::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { query: Some("codex".into()), queries: None, }), - } + }) ); assert_eq!( turns[0].items[2], @@ -3982,14 +3985,14 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { query: Some("codex".into()), queries: None, }), - }, + }), }], changed_turns: Vec::new(), removed_turn_ids: Vec::new(), @@ -4117,14 +4120,14 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { query: Some("codex".into()), queries: None, }), - }, + }), }], changed_turns: vec![ThreadHistoryTurnChange { turn_id: "rollout-0".into(), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 2fea01da6a..e5e131deb3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -13,6 +13,8 @@ use crate::protocol::item_builders::convert_patch_changes; use codex_experimental_api_macros::ExperimentalApi; use codex_extension_items::ExtensionItem; pub use codex_extension_items::image_generation::ImageGenerationItem; +pub use codex_extension_items::web_search::WebSearchAction; +pub use codex_extension_items::web_search::WebSearchItem; use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; @@ -358,13 +360,7 @@ pub enum ThreadItem { agent_thread_id: String, agent_path: String, }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WebSearch { - id: String, - query: String, - action: Option, - }, + WebSearch(WebSearchItem), #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ImageView { @@ -432,12 +428,12 @@ impl ThreadItem { | ThreadItem::DynamicToolCall { id, .. } | ThreadItem::CollabAgentToolCall { id, .. } | ThreadItem::SubAgentActivity { id, .. } - | ThreadItem::WebSearch { id, .. } | ThreadItem::ImageView { id, .. } | ThreadItem::Sleep { id, .. } | ThreadItem::EnteredReviewMode { id, .. } | ThreadItem::ExitedReviewMode { id, .. } | ThreadItem::ContextCompaction { id, .. } => id, + ThreadItem::WebSearch(item) => &item.id, ThreadItem::ImageGeneration(item) => &item.id, } } @@ -786,40 +782,20 @@ impl TryFrom for CoreGuardianAssessmentAction { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WebSearchAction { - Search { - query: Option, - queries: Option>, - }, - OpenPage { - url: Option, - }, - FindInPage { - url: Option, - pattern: Option, - }, - #[serde(other)] - Other, -} - -impl From for WebSearchAction { - fn from(value: codex_protocol::models::WebSearchAction) -> Self { - match value { - codex_protocol::models::WebSearchAction::Search { query, queries } => { - WebSearchAction::Search { query, queries } - } - codex_protocol::models::WebSearchAction::OpenPage { url } => { - WebSearchAction::OpenPage { url } - } - codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { - WebSearchAction::FindInPage { url, pattern } - } - codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, +pub(crate) fn web_search_action_from_core( + value: codex_protocol::models::WebSearchAction, +) -> WebSearchAction { + match value { + codex_protocol::models::WebSearchAction::Search { query, queries } => { + WebSearchAction::Search { query, queries } } + codex_protocol::models::WebSearchAction::OpenPage { url } => { + WebSearchAction::OpenPage { url } + } + codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { + WebSearchAction::FindInPage { url, pattern } + } + codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, } } @@ -921,11 +897,11 @@ impl From for ThreadItem { agent_thread_id: activity.agent_thread_id.to_string(), agent_path: String::from(activity.agent_path), }, - CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { + CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch(WebSearchItem { id: search.id, query: search.query, - action: Some(WebSearchAction::from(search.action)), - }, + action: Some(web_search_action_from_core(search.action)), + }), CoreTurnItem::ImageView(image) => ThreadItem::ImageView { id: image.id, path: image.path.into(), @@ -936,6 +912,7 @@ impl From for ThreadItem { }, CoreTurnItem::Extension(extension) => match extension { ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item), + ExtensionItem::WebSearch(item) => ThreadItem::WebSearch(item), }, CoreTurnItem::ImageGeneration(image) => { ThreadItem::ImageGeneration(ImageGenerationItem { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 377ecba2c2..d35d39fe46 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -19,7 +19,7 @@ use codex_protocol::items::ReasoningItem; use codex_protocol::items::SubAgentActivityItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; -use codex_protocol::items::WebSearchItem; +use codex_protocol::items::WebSearchItem as CoreWebSearchItem; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::McpServerInfo; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; @@ -2770,7 +2770,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { } ); - let search_item = TurnItem::WebSearch(WebSearchItem { + let search_item = TurnItem::WebSearch(CoreWebSearchItem { id: "search-1".to_string(), query: "docs".to_string(), action: CoreWebSearchAction::Search { @@ -2779,16 +2779,24 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, }); + let expected_search_item = WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + }; + assert_eq!( ThreadItem::from(search_item), - ThreadItem::WebSearch { - id: "search-1".to_string(), - query: "docs".to_string(), - action: Some(WebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }), - } + ThreadItem::WebSearch(expected_search_item.clone()) + ); + assert_eq!( + ThreadItem::from(TurnItem::Extension( + codex_extension_items::ExtensionItem::WebSearch(expected_search_item.clone()), + )), + ThreadItem::WebSearch(expected_search_item) ); let image_view_item = TurnItem::ImageView(ImageViewItem { diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index f7d05395b4..01477466e4 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::WebSearchAction; +use codex_app_server_protocol::WebSearchItem; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; @@ -183,20 +184,20 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { ); assert_eq!( started.item, - ThreadItem::WebSearch { + ThreadItem::WebSearch(WebSearchItem { id: call_id.to_string(), query: String::new(), - action: Some(WebSearchAction::Other), - } + action: None, + }) ); - let expected_completed_item = ThreadItem::WebSearch { + let expected_completed_item = ThreadItem::WebSearch(WebSearchItem { id: call_id.to_string(), query: "standalone web search".to_string(), action: Some(WebSearchAction::Search { query: Some("standalone web search".to_string()), queries: None, }), - }; + }); assert_eq!(completed.item, expected_completed_item); drop(mcp); @@ -223,7 +224,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { .turns .iter() .flat_map(|turn| &turn.items) - .filter(|item| matches!(item, ThreadItem::WebSearch { .. })) + .filter(|item| matches!(item, ThreadItem::WebSearch(_))) .collect(); assert_eq!(persisted_web_searches, vec![&expected_completed_item]); @@ -240,7 +241,7 @@ async fn wait_for_web_search_started(mcp: &mut TestAppServer) -> Result (TurnItem::WebSearch(item), Vec::new()), - ExtensionTurnItem::Extension { - item, - legacy_events, - } => (TurnItem::Extension(item), legacy_events), - }; + let ExtensionTurnItem { + item, + legacy_events, + } = item; + let item = TurnItem::Extension(item); session.emit_turn_item_started(turn.as_ref(), &item).await; emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) @@ -104,25 +100,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let (item, legacy_events) = match item { - ExtensionTurnItem::Extension { - item, - legacy_events, - } => (TurnItem::Extension(item), legacy_events), - ExtensionTurnItem::WebSearch(item) => { - let mut item = TurnItem::WebSearch(item); - finalize_turn_item( - session.as_ref(), - turn.as_ref(), - TurnItemContributorPolicy::Run(turn.extension_data.as_ref()), - &mut item, - turn.collaboration_mode.mode - == codex_protocol::config_types::ModeKind::Plan, - ) - .await; - (item, Vec::new()) - } - }; + let ExtensionTurnItem { + item, + legacy_events, + } = item; + let item = TurnItem::Extension(item); session.emit_turn_item_completed(turn.as_ref(), item).await; emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) @@ -181,11 +163,10 @@ mod tests { use codex_extension_items::ExtensionItem; use codex_extension_items::image_generation::ImageGenerationItem; + use codex_extension_items::web_search::WebSearchItem; use codex_protocol::items::TurnItem; - use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; - use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ImageGenerationBeginEvent; use codex_protocol::protocol::ImageGenerationEndEvent; @@ -274,16 +255,16 @@ mod tests { &self, call: codex_tools::ToolCall, ) -> Result, codex_tools::FunctionCallError> { - let item = ExtensionTurnItem::WebSearch(WebSearchItem { - id: call.call_id.clone(), - query: "rust trait object".to_string(), - action: WebSearchAction::Search { - query: Some("rust trait object".to_string()), - queries: None, - }, - }); - call.turn_item_emitter.emit_started(item.clone()).await; - call.turn_item_emitter.emit_completed(item).await; + call.turn_item_emitter + .emit_started(ExtensionTurnItem { + item: ExtensionItem::WebSearch(WebSearchItem { + id: call.call_id.clone(), + query: String::new(), + action: None, + }), + legacy_events: Vec::new(), + }) + .await; *self.captured_call.lock().await = Some(call); Ok( Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) @@ -420,39 +401,17 @@ mod tests { let EventMsg::ItemStarted(started) = started.msg else { panic!("expected item started event"); }; - let TurnItem::WebSearch(started_item) = started.item else { - panic!("expected web search item"); + let TurnItem::Extension(ExtensionItem::WebSearch(started_item)) = started.item else { + panic!("expected extension web search item"); }; - let begin = rx.recv().await.expect("legacy web search begin event"); - let EventMsg::WebSearchBegin(begin) = begin.msg else { - panic!("expected legacy web search begin event"); - }; - let completed = rx.recv().await.expect("item completed event"); - let EventMsg::ItemCompleted(completed) = completed.msg else { - panic!("expected item completed event"); - }; - let TurnItem::WebSearch(completed_item) = completed.item else { - panic!("expected web search item"); - }; - let end = rx.recv().await.expect("legacy web search end event"); - let EventMsg::WebSearchEnd(end) = end.msg else { - panic!("expected legacy web search end event"); - }; - - let expected = WebSearchItem { - id: "call-extension".to_string(), - query: "rust trait object".to_string(), - action: WebSearchAction::Search { - query: Some("rust trait object".to_string()), - queries: None, - }, - }; - assert_eq!(started_item, expected); - assert_eq!(completed_item, expected); - assert_eq!(begin.call_id, expected.id); - assert_eq!(end.call_id, expected.id); - assert_eq!(end.query, expected.query); - assert_eq!(end.action, expected.action); + assert_eq!( + started_item, + WebSearchItem { + id: "call-extension".to_string(), + query: String::new(), + action: None, + } + ); } #[tokio::test] @@ -484,7 +443,7 @@ mod tests { }); codex_tools::TurnItemEmitter::emit_started( &emitter, - ExtensionTurnItem::Extension { + ExtensionTurnItem { item: expected_started_item.clone(), legacy_events: vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { call_id: "call-image".to_string(), @@ -494,7 +453,7 @@ mod tests { .await; codex_tools::TurnItemEmitter::emit_completed( &emitter, - ExtensionTurnItem::Extension { + ExtensionTurnItem { item: expected_completed_item.clone(), legacy_events: vec![EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { call_id: "call-image".to_string(), diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index a667cdd53d..e3463d7d1d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -81,8 +81,8 @@ impl EventProcessorWithHumanOutput { "started".style(self.dimmed) ); } - ThreadItem::WebSearch { query, .. } => { - eprintln!("{} {}", "web search:".style(self.bold), query); + ThreadItem::WebSearch(item) => { + eprintln!("{} {}", "web search:".style(self.bold), item.query); } ThreadItem::FileChange { .. } => { eprintln!("{}", "apply patch".style(self.bold)); @@ -196,8 +196,8 @@ impl EventProcessorWithHumanOutput { eprintln!("{}", error.message.style(self.red)); } } - ThreadItem::WebSearch { query, .. } => { - eprintln!("{} {}", "web search:".style(self.bold), query); + ThreadItem::WebSearch(item) => { + eprintln!("{} {}", "web search:".style(self.bold), item.query); } ThreadItem::ContextCompaction { .. } => { eprintln!("{}", "context compacted".style(self.dimmed)); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 79bf88eaaf..fb9325e5b9 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -293,16 +293,12 @@ impl EventProcessorWithJsonOutput { }, }), }), - ThreadItem::WebSearch { - id: raw_id, - query, - action, - } => Some(ExecThreadItem { + ThreadItem::WebSearch(item) => Some(ExecThreadItem { id: make_id(), details: ThreadItemDetails::WebSearch(WebSearchItem { - id: raw_id, - query, - action: match action { + id: item.id, + query: item.query, + action: match item.action { Some(action) => serde_json::from_value( serde_json::to_value(action).unwrap_or_else(|_| json!("other")), ) diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 1f44ca31a8..3abdbd716b 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_app_server_protocol::WebSearchItem as ApiWebSearchItem; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; @@ -362,14 +363,14 @@ fn web_search_completion_preserves_query_and_action() { let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: "rust async await".to_string(), action: Some(ApiWebSearchAction::Search { query: Some("rust async await".to_string()), queries: None, }), - }, + }), thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, @@ -403,11 +404,11 @@ fn web_search_start_and_completion_reuse_item_id() { let started = processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: String::new(), action: None, - }, + }), thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), started_at_ms: 0, @@ -415,14 +416,14 @@ fn web_search_start_and_completion_reuse_item_id() { let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: "rust async await".to_string(), action: Some(ApiWebSearchAction::Search { query: Some("rust async await".to_string()), queries: None, }), - }, + }), thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 06a4351a42..3c73aaa5d4 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -101,7 +101,7 @@ fn legacy_end_event(item: &ImageGenerationItem) -> EventMsg { } fn extension_turn_item(item: ImageGenerationItem, legacy_event: EventMsg) -> ExtensionTurnItem { - ExtensionTurnItem::Extension { + ExtensionTurnItem { item: ExtensionItem::ImageGeneration(item), legacy_events: vec![legacy_event], } diff --git a/codex-rs/ext/items/src/lib.rs b/codex-rs/ext/items/src/lib.rs index 27eeb9abb0..2fe91cfac8 100644 --- a/codex-rs/ext/items/src/lib.rs +++ b/codex-rs/ext/items/src/lib.rs @@ -9,6 +9,7 @@ use serde::Serialize; use ts_rs::TS; pub mod image_generation; +pub mod web_search; /// Canonical extension-owned turn item carried through core lifecycle events. /// @@ -34,6 +35,9 @@ pub enum ExtensionItem { #[serde(rename = "image_gen.generation")] #[ts(rename = "image_gen.generation")] ImageGeneration(image_generation::ImageGenerationItem), + #[serde(rename = "web.search")] + #[ts(rename = "web.search")] + WebSearch(web_search::WebSearchItem), } impl ExtensionItem { @@ -42,6 +46,7 @@ impl ExtensionItem { pub fn id(&self) -> &str { match self { Self::ImageGeneration(item) => &item.id, + Self::WebSearch(item) => &item.id, } } } diff --git a/codex-rs/ext/items/src/tests.rs b/codex-rs/ext/items/src/tests.rs index 88575b4bfe..af4dd0c198 100644 --- a/codex-rs/ext/items/src/tests.rs +++ b/codex-rs/ext/items/src/tests.rs @@ -3,6 +3,8 @@ use serde_json::json; use super::ExtensionItem; use super::image_generation::ImageGenerationItem; +use super::web_search::WebSearchAction; +use super::web_search::WebSearchItem; fn completed_image_generation_item() -> ExtensionItem { ExtensionItem::ImageGeneration(ImageGenerationItem { @@ -35,6 +37,37 @@ fn image_generation_item_preserves_stable_wire_shape() { ); } +#[test] +fn web_search_item_preserves_stable_wire_shape() { + let item = ExtensionItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + }); + let value = serde_json::to_value(&item).expect("serialize extension item"); + + assert_eq!( + value, + json!({ + "kind": "web.search", + "id": "search-1", + "query": "docs", + "action": { + "type": "search", + "query": "docs", + "queries": null, + }, + }) + ); + assert_eq!( + serde_json::from_value::(value).expect("deserialize extension item"), + item + ); +} + #[test] fn unknown_extension_kind_is_rejected() { let value = json!({ diff --git a/codex-rs/ext/items/src/web_search.rs b/codex-rs/ext/items/src/web_search.rs new file mode 100644 index 0000000000..e878f4e7e2 --- /dev/null +++ b/codex-rs/ext/items/src/web_search.rs @@ -0,0 +1,39 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +// Standalone web-search item owned by the web extension. This is also the +// field-level representation exposed by app-server; core and rollout +// persistence only carry it inside an ExtensionItem envelope. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct WebSearchItem { + pub id: String, + pub query: String, + pub action: Option, +} + +// App-server-facing description of the action performed by standalone web search. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +// Keep app-server's existing v2 TS path. The root WebSearchAction name is +// already used by the snake_case Responses API action type. +#[ts(export_to = "v2/")] +pub enum WebSearchAction { + Search { + query: Option, + queries: Option>, + }, + OpenPage { + url: Option, + }, + FindInPage { + url: Option, + pattern: Option, + }, + #[serde(other)] + Other, +} diff --git a/codex-rs/ext/web-search/Cargo.toml b/codex-rs/ext/web-search/Cargo.toml index d007d6146d..d9ea4b0260 100644 --- a/codex-rs/ext/web-search/Cargo.toml +++ b/codex-rs/ext/web-search/Cargo.toml @@ -16,6 +16,7 @@ workspace = true codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } +codex-extension-items = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 9b8dbf56d6..92d3041e72 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -14,10 +14,15 @@ use codex_extension_api::ToolName; use codex_extension_api::ToolOutput; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema_without_compaction; +use codex_extension_items::ExtensionItem; +use codex_extension_items::web_search::WebSearchAction; +use codex_extension_items::web_search::WebSearchItem; use codex_login::default_client::build_reqwest_client; use codex_model_provider::SharedModelProvider; -use codex_protocol::items::WebSearchItem; -use codex_protocol::models::WebSearchAction; +use codex_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::WebSearchBeginEvent; +use codex_protocol::protocol::WebSearchEndEvent; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolExposure; @@ -109,14 +114,47 @@ impl WebSearchTool { ), }; call.turn_item_emitter - .emit_started(web_search_item(&call.call_id, WebSearchAction::Other)) + .emit_started(extension_turn_item( + WebSearchItem { + id: call.call_id.clone(), + query: String::new(), + action: None, + }, + EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: call.call_id.clone(), + }), + )) .await; let response = client .search(&request, HeaderMap::new()) .await .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + let legacy_action = match &command_action { + WebSearchAction::Search { query, queries } => CoreWebSearchAction::Search { + query: query.clone(), + queries: queries.clone(), + }, + WebSearchAction::OpenPage { url } => CoreWebSearchAction::OpenPage { url: url.clone() }, + WebSearchAction::FindInPage { url, pattern } => CoreWebSearchAction::FindInPage { + url: url.clone(), + pattern: pattern.clone(), + }, + WebSearchAction::Other => CoreWebSearchAction::Other, + }; + let query = web_search_action_detail(&legacy_action); call.turn_item_emitter - .emit_completed(web_search_item(&call.call_id, command_action)) + .emit_completed(extension_turn_item( + WebSearchItem { + id: call.call_id.clone(), + query: query.clone(), + action: Some(command_action), + }, + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: call.call_id.clone(), + query, + action: legacy_action, + }), + )) .await; Ok(Box::new(SearchOutput::new(response.output))) @@ -180,18 +218,17 @@ fn literal_url(ref_id: &str) -> Option { Url::parse(ref_id).is_ok().then(|| ref_id.to_string()) } -fn web_search_item(call_id: &str, action: WebSearchAction) -> ExtensionTurnItem { - ExtensionTurnItem::WebSearch(WebSearchItem { - id: call_id.to_string(), - query: web_search_action_detail(&action), - action, - }) +fn extension_turn_item(item: WebSearchItem, legacy_event: EventMsg) -> ExtensionTurnItem { + ExtensionTurnItem { + item: ExtensionItem::WebSearch(item), + legacy_events: vec![legacy_event], + } } #[cfg(test)] mod tests { use codex_api::SearchCommands; - use codex_protocol::models::WebSearchAction; + use codex_extension_items::web_search::WebSearchAction; use pretty_assertions::assert_eq; use super::command_action; diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 2c29306cda..2616bb5899 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -47,13 +47,17 @@ pub enum TurnItem { DynamicToolCall(DynamicToolCallItem), CollabAgentToolCall(CollabAgentToolCallItem), SubAgentActivity(SubAgentActivityItem), + /// Hosted Responses API web-search item handled directly by core. + /// + /// Standalone web search uses Self::Extension instead because its display + /// schema is owned by the web-search extension. WebSearch(WebSearchItem), ImageView(ImageViewItem), Sleep(SleepItem), /// Item whose schema and lifecycle details are owned by an extension. /// - /// Standalone image generation uses this path. App-server wraps the same - /// typed item in its public image-generation variant. + /// Standalone image generation and web search use this path. App-server + /// wraps the same typed items in their public variants. Extension(ExtensionItem), /// Hosted Responses API image-generation item handled directly by core. /// diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 8810ea4376..b669014061 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -4,7 +4,6 @@ use crate::ToolPayload; use codex_extension_items::ExtensionItem; use codex_file_system::ExecutorFileSystem; use codex_file_system::FileSystemSandboxContext; -use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_utils_absolute_path::AbsolutePathBuf; @@ -36,8 +35,7 @@ pub type TurnItemEmissionFuture<'a> = Pin + Send + ' /// Visible turn items that an extension may publish into the host lifecycle. #[derive(Clone, Debug)] -pub enum ExtensionTurnItem { - WebSearch(WebSearchItem), +pub struct ExtensionTurnItem { /// Canonical extension item plus compatibility events derived by its owner. /// /// Core intentionally does not inspect extension-owned payloads, so it @@ -45,10 +43,8 @@ pub enum ExtensionTurnItem { /// event first, then these extension-provided events. Core also skips /// global turn-item contributors here so extensions cannot mutate items /// owned by other extensions. - Extension { - item: ExtensionItem, - legacy_events: Vec, - }, + pub item: ExtensionItem, + pub legacy_events: Vec, } /// Host-provided capability for extension tools to emit visible turn items. diff --git a/codex-rs/tui/src/app/agent_status_feed.rs b/codex-rs/tui/src/app/agent_status_feed.rs index 91f8784f6d..96f2c67bcd 100644 --- a/codex-rs/tui/src/app/agent_status_feed.rs +++ b/codex-rs/tui/src/app/agent_status_feed.rs @@ -178,8 +178,8 @@ fn activity_summary(item: &ThreadItem) -> Option { }; return bounded_summary(&format!("{action} {agent_path}")); } - ThreadItem::WebSearch { query, .. } => { - return bounded_summary(&format!("Web search: {query}")); + ThreadItem::WebSearch(item) => { + return bounded_summary(&format!("Web search: {}", item.query)); } ThreadItem::ImageView { path, .. } => { let path = path.render_for_ui(); diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index df03fb1c74..f60f9c41da 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -290,8 +290,8 @@ impl ChatWidget { self.on_patch_apply_begin(file_update_changes_to_display(changes)); } item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item), - ThreadItem::WebSearch { id, .. } => { - self.on_web_search_begin(id); + ThreadItem::WebSearch(item) => { + self.on_web_search_begin(item.id); } ThreadItem::ImageGeneration(_) => { self.on_image_generation_begin(); diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 8b454fa0c4..91cb33c30c 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -139,12 +139,13 @@ impl ChatWidget { .. } => self.on_mcp_tool_call_started(item), item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), - ThreadItem::WebSearch { id, query, action } => { - self.on_web_search_begin(id.clone()); + ThreadItem::WebSearch(item) => { + self.on_web_search_begin(item.id.clone()); self.on_web_search_end( - id, - query, - action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), + item.id, + item.query, + item.action + .unwrap_or(codex_app_server_protocol::WebSearchAction::Other), ); } ThreadItem::ImageView { id: _, path } => { diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index 2e43f40d95..132a9e3c72 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -199,8 +199,8 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option { } => { vec![sub_agent_activity_summary(*kind, agent_path).dim().into()] } - ThreadItem::WebSearch { query, .. } => { - vec![vec!["web search: ".dim(), query.clone().into()].into()] + ThreadItem::WebSearch(item) => { + vec![vec!["web search: ".dim(), item.query.clone().into()].into()] } ThreadItem::ImageView { path, .. } => { let path = path.render_for_ui(); From 23aac925e7b3fd02b9d6a47147b7b8310a124b49 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 8 Jul 2026 09:59:50 -0700 Subject: [PATCH 32/53] feat(core): emit canonical review mode items (#31473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR moves review-mode markers onto canonical `TurnItem` lifecycle: - `TurnItem::EnteredReviewMode` - `TurnItem::ExitedReviewMode` Core now emits `ItemStarted` / `ItemCompleted` for both. The completed items map back into the existing `EnteredReviewMode` / `ExitedReviewMode` events, so raw core event consumers and legacy rollout persistence keep seeing the old events. This is the compatibility layer needed before paginated rollouts persist review markers as `ItemCompleted(TurnItem)`. ## Why Review markers were one of the remaining app-server thread items created directly from legacy events. Giving them canonical items lets paginated history persist stable turn/item IDs without changing legacy rollouts. ## What changed - Added canonical review-mode `TurnItem`s and switched review flow to emit their lifecycle. - Added completed-item → legacy review event mappings with stable turn/item IDs. - Switched app-server live notifications to the generic canonical item path and kept legacy replay compatible with old payloads. - Updated `ThreadHistoryBuilder` to replay canonical review items even though review turns still do not emit `TurnStarted`. --- .../src/protocol/item_builders.rs | 9 + .../src/protocol/thread_history.rs | 192 +++++++++++++++--- .../src/protocol/v2/item.rs | 9 + .../app-server/src/bespoke_event_handling.rs | 88 +------- codex-rs/app-server/tests/suite/v2/review.rs | 8 +- codex-rs/core/Cargo.toml | 2 +- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/review.rs | 11 +- codex-rs/core/src/tasks/review.rs | 22 +- codex-rs/core/tests/suite/review.rs | 101 +++++++-- codex-rs/protocol/src/items.rs | 19 ++ codex-rs/protocol/src/legacy_events.rs | 32 +++ codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 102 +++++++++- .../{core => protocol}/src/review_format.rs | 10 +- codex-rs/rollout/src/persistence_metrics.rs | 2 + 17 files changed, 458 insertions(+), 153 deletions(-) rename codex-rs/{core => protocol}/src/review_format.rs (89%) diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index c041fe9621..751bf4f67b 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -33,6 +33,9 @@ use codex_protocol::protocol::GuardianAssessmentAction; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::review_format::REVIEW_FALLBACK_MESSAGE; +use codex_protocol::review_format::render_review_output_text; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; use codex_utils_path_uri::PathConvention; @@ -41,6 +44,12 @@ use std::collections::HashMap; use std::path::PathBuf; use tracing::warn; +pub(crate) fn review_output_text(output: Option<&ReviewOutputEvent>) -> String { + output + .map(render_review_output_text) + .unwrap_or_else(|| REVIEW_FALLBACK_MESSAGE.to_string()) +} + pub fn build_file_change_approval_request_item( payload: &ApplyPatchApprovalRequestEvent, ) -> ThreadItem { diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 08fb4ecd5b..9f7ffa4447 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -4,6 +4,7 @@ use crate::protocol::item_builders::build_file_change_approval_request_item; use crate::protocol::item_builders::build_file_change_begin_item; use crate::protocol::item_builders::build_file_change_end_item; use crate::protocol::item_builders::build_item_from_guardian_event; +use crate::protocol::item_builders::review_output_text; use crate::protocol::v2::CollabAgentState; use crate::protocol::v2::CollabAgentTool; use crate::protocol::v2::CollabAgentToolCallStatus; @@ -49,7 +50,6 @@ use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; -use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortedEvent; @@ -59,6 +59,8 @@ use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::ViewImageToolCallEvent; use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; +#[cfg(test)] +use codex_protocol::review_format::REVIEW_FALLBACK_MESSAGE; use std::collections::HashMap; use tracing::warn; use uuid::Uuid; @@ -590,6 +592,11 @@ impl ThreadHistoryBuilder { turn_id: &str, item: &codex_protocol::items::TurnItem, ) { + let is_review_mode_item = matches!( + item, + codex_protocol::items::TurnItem::EnteredReviewMode(_) + | codex_protocol::items::TurnItem::ExitedReviewMode(_) + ); let should_upsert = match item { codex_protocol::items::TurnItem::Plan(plan) => !plan.text.is_empty(), codex_protocol::items::TurnItem::Sleep(_) @@ -597,7 +604,9 @@ impl ThreadHistoryBuilder { | codex_protocol::items::TurnItem::DynamicToolCall(_) | codex_protocol::items::TurnItem::CollabAgentToolCall(_) | codex_protocol::items::TurnItem::SubAgentActivity(_) - | codex_protocol::items::TurnItem::Extension(_) => true, + | codex_protocol::items::TurnItem::Extension(_) + | codex_protocol::items::TurnItem::EnteredReviewMode(_) + | codex_protocol::items::TurnItem::ExitedReviewMode(_) => true, codex_protocol::items::TurnItem::UserMessage(_) | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::AgentMessage(_) @@ -611,7 +620,12 @@ impl ThreadHistoryBuilder { }; if should_upsert { - self.upsert_item_in_turn_id(turn_id, ThreadItem::from(item.clone())); + let item = ThreadItem::from(item.clone()); + if is_review_mode_item { + self.upsert_review_mode_item(Some(turn_id), item); + } else { + self.upsert_item_in_turn_id(turn_id, item); + } } } @@ -1108,26 +1122,55 @@ impl ThreadHistoryBuilder { self.push_item_in_current_turn(ThreadItem::ContextCompaction { id }); } - fn handle_entered_review_mode(&mut self, payload: &codex_protocol::protocol::ReviewRequest) { + fn handle_entered_review_mode( + &mut self, + payload: &codex_protocol::protocol::EnteredReviewModeEvent, + ) { let review = payload .user_facing_hint .clone() .unwrap_or_else(|| "Review requested.".to_string()); - let id = self.next_item_id(); - self.push_item_in_current_turn(ThreadItem::EnteredReviewMode { id, review }); + let id = payload + .item_id + .clone() + .unwrap_or_else(|| self.next_item_id()); + self.upsert_review_mode_item( + payload.turn_id.as_deref(), + ThreadItem::EnteredReviewMode { id, review }, + ); } fn handle_exited_review_mode( &mut self, payload: &codex_protocol::protocol::ExitedReviewModeEvent, ) { - let review = payload - .review_output + let review = review_output_text(payload.review_output.as_ref()); + let id = payload + .item_id + .clone() + .unwrap_or_else(|| self.next_item_id()); + self.upsert_review_mode_item( + payload.turn_id.as_deref(), + ThreadItem::ExitedReviewMode { id, review }, + ); + } + + fn upsert_review_mode_item(&mut self, turn_id: Option<&str>, item: ThreadItem) { + let Some(turn_id) = turn_id else { + self.upsert_item_in_current_turn(item); + return; + }; + let current_turn_matches = self + .current_turn .as_ref() - .map(render_review_output_text) - .unwrap_or_else(|| REVIEW_FALLBACK_MESSAGE.to_string()); - let id = self.next_item_id(); - self.push_item_in_current_turn(ThreadItem::ExitedReviewMode { id, review }); + .is_some_and(|turn| turn.id == turn_id); + if !current_turn_matches && !self.turns.iter().any(|turn| turn.id == turn_id) { + self.finish_current_turn(); + let turn = self.new_turn(Some(turn_id.to_string())); + self.record_changed_pending_turn(&turn); + self.current_turn = Some(turn); + } + self.upsert_item_in_turn_id(turn_id, item); } fn handle_error(&mut self, payload: &ErrorEvent) { @@ -1443,17 +1486,6 @@ impl ThreadHistoryBuilder { } } -const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; - -fn render_review_output_text(output: &ReviewOutputEvent) -> String { - let explanation = output.overall_explanation.trim(); - if explanation.is_empty() { - REVIEW_FALLBACK_MESSAGE.to_string() - } else { - explanation.to_string() - } -} - fn convert_dynamic_tool_content_items( items: &[codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem], ) -> Vec { @@ -1558,6 +1590,8 @@ mod tests { use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::items::CommandExecutionItem as CoreCommandExecutionItem; use codex_protocol::items::CommandExecutionStatus as CoreCommandExecutionStatus; + use codex_protocol::items::EnteredReviewModeItem as CoreEnteredReviewModeItem; + use codex_protocol::items::ExitedReviewModeItem as CoreExitedReviewModeItem; use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment; use codex_protocol::items::SleepItem as CoreSleepItem; use codex_protocol::items::TurnItem as CoreTurnItem; @@ -1575,12 +1609,15 @@ mod tests { use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::DynamicToolCallResponseEvent; + use codex_protocol::protocol::EnteredReviewModeEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::PatchApplyBeginEvent; + use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; @@ -1705,6 +1742,111 @@ mod tests { ); } + #[test] + fn review_mode_events_replay_persisted_ids() { + let events = vec![ + EventMsg::EnteredReviewMode(EnteredReviewModeEvent { + target: ReviewTarget::Custom { + instructions: "review this".into(), + }, + user_facing_hint: Some("Review requested.".into()), + turn_id: Some("turn-1".into()), + item_id: Some("entered-review".into()), + }), + EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + turn_id: Some("turn-1".into()), + item_id: Some("exited-review".into()), + review_output: None, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); + + assert_eq!(turns[0].id, "turn-1"); + assert_eq!( + turns[0].items, + vec![ + ThreadItem::EnteredReviewMode { + id: "entered-review".into(), + review: "Review requested.".into(), + }, + ThreadItem::ExitedReviewMode { + id: "exited-review".into(), + review: REVIEW_FALLBACK_MESSAGE.into(), + }, + ] + ); + } + + #[test] + fn review_mode_items_replay_without_turn_started() { + let thread_id = ThreadId::new(); + let entered = CoreTurnItem::EnteredReviewMode(CoreEnteredReviewModeItem { + id: "entered-review".into(), + target: ReviewTarget::Custom { + instructions: "review this".into(), + }, + user_facing_hint: "Review requested.".into(), + }); + let exited = CoreTurnItem::ExitedReviewMode(CoreExitedReviewModeItem { + id: "exited-review".into(), + review_output: None, + }); + let events = vec![ + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: "turn-1".into(), + item: entered, + completed_at_ms: 0, + }), + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: "turn-1".into(), + item: exited, + completed_at_ms: 0, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); + + assert_eq!(turns[0].id, "turn-1"); + assert_eq!( + turns[0].items, + vec![ + ThreadItem::EnteredReviewMode { + id: "entered-review".into(), + review: "Review requested.".into(), + }, + ThreadItem::ExitedReviewMode { + id: "exited-review".into(), + review: REVIEW_FALLBACK_MESSAGE.into(), + }, + ] + ); + } + #[test] fn rebuilds_user_message_image_details_from_legacy_events() { let local_path = PathBuf::from("/tmp/local.png"); @@ -3932,7 +4074,6 @@ mod tests { ..Default::default() }), )); - assert_eq!( changes, ThreadHistoryChangeSet { @@ -3979,7 +4120,6 @@ mod tests { }, }), )); - assert_eq!( changes, ThreadHistoryChangeSet { @@ -4014,7 +4154,6 @@ mod tests { text: "raw content".into(), }), )); - assert_eq!( changes, ThreadHistoryChangeSet { @@ -4114,7 +4253,6 @@ mod tests { }, })), ]); - assert_eq!( changes, ThreadHistoryChangeSet { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index e5e131deb3..7a5e4dc1f4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -10,6 +10,7 @@ use super::UserInput; use super::shared::v2_enum_from_core; use crate::protocol::item_builders::command_actions_for_path_uri; use crate::protocol::item_builders::convert_patch_changes; +use crate::protocol::item_builders::review_output_text; use codex_experimental_api_macros::ExperimentalApi; use codex_extension_items::ExtensionItem; pub use codex_extension_items::image_generation::ImageGenerationItem; @@ -923,6 +924,14 @@ impl From for ThreadItem { saved_path: image.saved_path, }) } + CoreTurnItem::EnteredReviewMode(review) => ThreadItem::EnteredReviewMode { + id: review.id, + review: review.user_facing_hint, + }, + CoreTurnItem::ExitedReviewMode(review) => ThreadItem::ExitedReviewMode { + id: review.id, + review: review_output_text(review.review_output.as_ref()), + }, CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { id: file_change.id, changes: convert_patch_changes(&file_change.changes), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b6f47dfe82..db5e505eb4 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -86,8 +86,6 @@ use codex_app_server_protocol::guardian_auto_approval_review_notification; use codex_app_server_protocol::item_event_to_server_notification; use codex_core::CodexThread; use codex_core::ThreadManager; -use codex_core::review_format::format_review_findings_block; -use codex_core::review_prompts; use codex_protocol::ThreadId; use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool; use codex_protocol::items::TurnItem as CoreTurnItem; @@ -101,7 +99,6 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::SubAgentActivityKind; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TurnAbortedEvent; @@ -837,11 +834,13 @@ pub(crate) async fn apply_bespoke_event_handling( | EventMsg::CollabResumeEnd(_) | EventMsg::SubAgentActivity(_) | EventMsg::ExecCommandBegin(_) - | EventMsg::ExecCommandEnd(_) => { + | EventMsg::ExecCommandEnd(_) + | EventMsg::EnteredReviewMode(_) + | EventMsg::ExitedReviewMode(_) => { // Deprecated item lifecycle events are still fanned out for raw-event and rollout // compatibility consumers. - // App-server v2 receives canonical TurnItem lifecycle instead, and dispatches - // dynamic tool requests from canonical DynamicToolCall starts. + // App-server v2 receives TurnItem lifecycle instead, and dispatches dynamic tool + // requests from DynamicToolCall starts. } EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => { // Deprecated MCP tool-call events are still fanned out for raw-event and rollout @@ -938,33 +937,6 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::ViewImageToolCall(_) => {} - EventMsg::EnteredReviewMode(review_request) => { - let review = review_request - .user_facing_hint - .unwrap_or_else(|| review_prompts::user_facing_hint(&review_request.target)); - let item = ThreadItem::EnteredReviewMode { - id: event_turn_id.clone(), - review, - }; - let started = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - item: item.clone(), - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(started)) - .await; - let completed = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(completed)) - .await; - } EventMsg::ItemStarted(event) => { let should_emit = match &event.item { // Approval and guardian flows can emit the command start notification before core @@ -1049,34 +1021,6 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::HookCompleted(notification)) .await; } - EventMsg::ExitedReviewMode(review_event) => { - let review = match review_event.review_output { - Some(output) => render_review_output_text(&output), - None => REVIEW_FALLBACK_MESSAGE.to_string(), - }; - let item = ThreadItem::ExitedReviewMode { - id: event_turn_id.clone(), - review, - }; - let started = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - item: item.clone(), - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(started)) - .await; - let completed = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(completed)) - .await; - } EventMsg::RawResponseItem(raw_response_item_event) => { maybe_emit_hook_prompt_item_completed( conversation_id, @@ -1952,28 +1896,6 @@ fn request_permissions_response_from_client_result( })) } -const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; - -fn render_review_output_text(output: &ReviewOutputEvent) -> String { - let mut sections = Vec::new(); - let explanation = output.overall_explanation.trim(); - if !explanation.is_empty() { - sections.push(explanation.to_string()); - } - if !output.findings.is_empty() { - let findings = format_review_findings_block(&output.findings, /*selection*/ None); - let trimmed = findings.trim(); - if !trimmed.is_empty() { - sections.push(trimmed.to_string()); - } - } - if sections.is_empty() { - REVIEW_FALLBACK_MESSAGE.to_string() - } else { - sections.join("\n\n") - } -} - fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision { match decision { FileChangeApprovalDecision::Accept => ReviewDecision::Approved, diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 06f0242476..786a3eab5b 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -113,8 +113,8 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() let started: ItemStartedNotification = serde_json::from_value(item_started.params.expect("params must be present"))?; match started.item { - ThreadItem::EnteredReviewMode { id, review } => { - assert_eq!(id, turn_id); + ThreadItem::EnteredReviewMode { review, .. } => { + assert_eq!(started.turn_id, turn_id); assert_eq!(review, "commit 1234567: Tidy UI colors"); saw_entered_review_mode = true; break; @@ -139,8 +139,8 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() let completed: ItemCompletedNotification = serde_json::from_value(review_notif.params.expect("params must be present"))?; match completed.item { - ThreadItem::ExitedReviewMode { id, review } => { - assert_eq!(id, turn_id); + ThreadItem::ExitedReviewMode { review, .. } => { + assert_eq!(completed.turn_id, turn_id); review_body = Some(review); break; } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 5dc2586b6a..a575f32df5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -118,7 +118,7 @@ toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } -uuid = { workspace = true, features = ["serde", "v4", "v5"] } +uuid = { workspace = true, features = ["serde", "v4", "v5", "v7"] } which = { workspace = true } whoami = { workspace = true } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e57288f05d..cfc26c5711 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -110,7 +110,6 @@ pub mod windows_sandbox; pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; pub use codex_protocol::config_types::ModelProviderAuthInfo; mod event_mapping; -pub mod review_format; pub use codex_prompts as review_prompts; mod thread_manager; pub(crate) mod web_search; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index af2412e5c4..02c8a67473 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -97,6 +97,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::items::EnteredReviewModeItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::ActivePermissionProfile; @@ -117,7 +118,6 @@ use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RawResponseItemEvent; -use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 1587290d33..33de46ede0 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -165,10 +165,11 @@ pub(super) async fn spawn_review_thread( sess.spawn_task(tc.clone(), input, ReviewTask::new()).await; // Announce entering review mode so UIs can switch modes. - let review_request = ReviewRequest { + let item = TurnItem::EnteredReviewMode(EnteredReviewModeItem { + id: uuid::Uuid::now_v7().to_string(), target: resolved.target, - user_facing_hint: Some(resolved.user_facing_hint), - }; - sess.send_event(&tc, EventMsg::EnteredReviewMode(review_request)) - .await; + user_facing_hint: resolved.user_facing_hint, + }); + sess.emit_turn_item_started(&tc, &item).await; + sess.emit_turn_item_completed(&tc, item).await; } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 19ac0fdc35..30f8d47d81 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use codex_prompts::render_review_exit_interrupted; use codex_prompts::render_review_exit_success; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::items::ExitedReviewModeItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -10,16 +11,15 @@ use codex_protocol::protocol::AgentMessageContentDeltaEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::review_format::format_review_findings_block; +use codex_protocol::review_format::render_review_output_text; use tokio_util::sync::CancellationToken; use crate::codex_delegate::run_codex_thread_one_shot; use crate::config::Constrained; -use crate::review_format::format_review_findings_block; -use crate::review_format::render_review_output_text; use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -210,8 +210,8 @@ fn parse_review_output_event(text: &str) -> ReviewOutputEvent { } } -/// Emits an ExitedReviewMode Event with optional ReviewOutput, -/// and records a developer message with the review output. +/// Emits ExitedReviewMode item lifecycle with optional ReviewOutput, +/// and records the review output back into conversation history. pub(crate) async fn exit_review_mode( session: Arc, review_output: Option, @@ -253,12 +253,12 @@ pub(crate) async fn exit_review_mode( ) .await; - session - .send_event( - ctx.as_ref(), - EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output }), - ) - .await; + let item = TurnItem::ExitedReviewMode(ExitedReviewModeItem { + id: uuid::Uuid::now_v7().to_string(), + review_output, + }); + session.emit_turn_item_started(ctx.as_ref(), &item).await; + session.emit_turn_item_completed(ctx.as_ref(), item).await; session .record_response_item_and_emit_turn_item( ctx.as_ref(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index e403b7e1ff..b3ba4f2597 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -1,7 +1,7 @@ use codex_core::CodexThread; use codex_core::REVIEW_PROMPT; use codex_core::config::Config; -use codex_core::review_format::render_review_output_text; +use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; @@ -16,6 +16,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::review_format::render_review_output_text; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::responses; @@ -35,9 +36,8 @@ use tokio::io::AsyncWriteExt as _; use uuid::Uuid; use wiremock::MockServer; -/// Verify that submitting `Op::Review` spawns a child task and emits -/// EnteredReviewMode -> ExitedReviewMode(None) -> TurnComplete -/// in that order when the model returns a structured review JSON payload. +/// Verify that submitting `Op::Review` emits review item lifecycle, +/// legacy review events, and TurnComplete when the model returns a structured review payload. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_op_emits_lifecycle_and_review_output() { // Skip under Codex sandbox network restrictions. @@ -84,13 +84,82 @@ async fn review_op_emits_lifecycle_and_review_output() { .await .unwrap(); - // Verify lifecycle: Entered -> Exited(Some(review)) -> TurnComplete. - let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; + // Item lifecycle events are emitted first, then the legacy review event is fanned out + // with the same stable IDs for compatibility consumers. + let entered_started = wait_for_event(&codex, |ev| { + matches!( + ev, + EventMsg::ItemStarted(event) + if matches!(event.item, TurnItem::EnteredReviewMode(_)) + ) + }) + .await; + let (review_turn_id, entered_item_id) = match entered_started { + EventMsg::ItemStarted(event) => (event.turn_id, event.item.id()), + other => panic!("expected entered review item start, got {other:?}"), + }; + let entered_completed = wait_for_event(&codex, |ev| { + matches!( + ev, + EventMsg::ItemCompleted(event) + if matches!(event.item, TurnItem::EnteredReviewMode(_)) + ) + }) + .await; + match entered_completed { + EventMsg::ItemCompleted(event) => { + assert_eq!(event.turn_id, review_turn_id); + assert_eq!(event.item.id(), entered_item_id); + } + other => panic!("expected entered review item completion, got {other:?}"), + } + let entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; + match entered { + EventMsg::EnteredReviewMode(event) => { + assert_eq!(event.turn_id.as_deref(), Some(review_turn_id.as_str())); + assert_eq!(event.item_id.as_deref(), Some(entered_item_id.as_str())); + } + other => panic!("expected EnteredReviewMode(..), got {other:?}"), + } + + let exited_started = wait_for_event(&codex, |ev| { + matches!( + ev, + EventMsg::ItemStarted(event) + if matches!(event.item, TurnItem::ExitedReviewMode(_)) + ) + }) + .await; + let exited_item_id = match exited_started { + EventMsg::ItemStarted(event) => { + assert_eq!(event.turn_id, review_turn_id); + event.item.id() + } + other => panic!("expected exited review item start, got {other:?}"), + }; + let exited_completed = wait_for_event(&codex, |ev| { + matches!( + ev, + EventMsg::ItemCompleted(event) + if matches!(event.item, TurnItem::ExitedReviewMode(_)) + ) + }) + .await; + match exited_completed { + EventMsg::ItemCompleted(event) => { + assert_eq!(event.turn_id, review_turn_id); + assert_eq!(event.item.id(), exited_item_id); + } + other => panic!("expected exited review item completion, got {other:?}"), + } let closed = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExitedReviewMode(_))).await; let review = match closed { - EventMsg::ExitedReviewMode(ev) => ev - .review_output - .expect("expected ExitedReviewMode with Some(review_output)"), + EventMsg::ExitedReviewMode(ev) => { + assert_eq!(ev.turn_id.as_deref(), Some(review_turn_id.as_str())); + assert_eq!(ev.item_id.as_deref(), Some(exited_item_id.as_str())); + ev.review_output + .expect("expected ExitedReviewMode with Some(review_output)") + } other => panic!("expected ExitedReviewMode(..), got {other:?}"), }; @@ -271,7 +340,7 @@ async fn cancelled_review_does_not_forward_delegate_mcp_startup() { event @ (EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_)) => { panic!("cancelled review forwarded delegate MCP startup: {event:?}") } - EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output }) => { + EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output, .. }) => { assert_eq!(review_output, None); exited_review = true; } @@ -512,7 +581,8 @@ async fn review_uses_custom_review_model_from_config() { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None + review_output: None, + .. }) ) }) @@ -561,7 +631,8 @@ async fn review_uses_session_model_when_review_model_unset() { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None + review_output: None, + .. }) ) }) @@ -677,7 +748,8 @@ async fn review_input_isolated_from_parent_history() { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None + review_output: None, + .. }) ) }) @@ -788,7 +860,8 @@ async fn review_history_surfaces_in_parent_session() { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: Some(_) + review_output: Some(_), + .. }) ) }) diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 2616bb5899..8fb2e4bb99 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -16,6 +16,8 @@ use crate::protocol::ExecCommandSource; use crate::protocol::ExecCommandStatus; use crate::protocol::FileChange; use crate::protocol::PatchApplyStatus; +use crate::protocol::ReviewOutputEvent; +use crate::protocol::ReviewTarget; use crate::protocol::SubAgentActivityKind; use crate::user_input::ByteRange; use crate::user_input::TextElement; @@ -64,6 +66,8 @@ pub enum TurnItem { /// This remains separate from [`Self::Extension`] because core still owns /// hosted image persistence and legacy-event fanout. ImageGeneration(ImageGenerationItem), + EnteredReviewMode(EnteredReviewModeItem), + ExitedReviewMode(ExitedReviewModeItem), FileChange(FileChangeItem), McpToolCall(McpToolCallItem), ContextCompaction(ContextCompactionItem), @@ -129,6 +133,19 @@ pub struct AgentMessageItem { pub memory_citation: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct EnteredReviewModeItem { + pub id: String, + pub target: ReviewTarget, + pub user_facing_hint: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct ExitedReviewModeItem { + pub id: String, + pub review_output: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct PlanItem { pub id: String, @@ -621,6 +638,8 @@ impl TurnItem { TurnItem::Sleep(item) => item.id.clone(), TurnItem::Extension(item) => item.id().to_string(), TurnItem::ImageGeneration(item) => item.id.clone(), + TurnItem::EnteredReviewMode(item) => item.id.clone(), + TurnItem::ExitedReviewMode(item) => item.id.clone(), TurnItem::FileChange(item) => item.id.clone(), TurnItem::McpToolCall(item) => item.id.clone(), TurnItem::ContextCompaction(item) => item.id.clone(), diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs index 12c2b98d1e..0d0dee50f8 100644 --- a/codex-rs/protocol/src/legacy_events.rs +++ b/codex-rs/protocol/src/legacy_events.rs @@ -10,6 +10,8 @@ use crate::items::CommandExecutionStatus; use crate::items::ContextCompactionItem; use crate::items::DynamicToolCallItem; use crate::items::DynamicToolCallStatus; +use crate::items::EnteredReviewModeItem; +use crate::items::ExitedReviewModeItem; use crate::items::FileChangeItem; use crate::items::ImageGenerationItem; use crate::items::McpToolCallItem; @@ -36,10 +38,12 @@ use crate::protocol::CollabWaitingBeginEvent; use crate::protocol::CollabWaitingEndEvent; use crate::protocol::ContextCompactedEvent; use crate::protocol::DynamicToolCallResponseEvent; +use crate::protocol::EnteredReviewModeEvent; use crate::protocol::EventMsg; use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; use crate::protocol::ExecCommandStatus; +use crate::protocol::ExitedReviewModeEvent; use crate::protocol::ImageGenerationBeginEvent; use crate::protocol::ImageGenerationEndEvent; use crate::protocol::ItemCompletedEvent; @@ -101,6 +105,27 @@ impl AgentMessageItem { } } +impl EnteredReviewModeItem { + pub fn as_legacy_event(&self, turn_id: String) -> EventMsg { + EventMsg::EnteredReviewMode(EnteredReviewModeEvent { + target: self.target.clone(), + user_facing_hint: Some(self.user_facing_hint.clone()), + turn_id: Some(turn_id), + item_id: Some(self.id.clone()), + }) + } +} + +impl ExitedReviewModeItem { + pub fn as_legacy_event(&self, turn_id: String) -> EventMsg { + EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + turn_id: Some(turn_id), + item_id: Some(self.id.clone()), + review_output: self.review_output.clone(), + }) + } +} + impl ReasoningItem { pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { let mut events = Vec::new(); @@ -489,6 +514,7 @@ impl TurnItem { TurnItem::Sleep(_) => Vec::new(), TurnItem::Extension(_) => Vec::new(), TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()], + TurnItem::EnteredReviewMode(_) | TurnItem::ExitedReviewMode(_) => Vec::new(), TurnItem::FileChange(item) => item .as_legacy_end_event(String::new()) .into_iter() @@ -551,6 +577,12 @@ impl HasLegacyEvent for ItemCompletedEvent { TurnItem::SubAgentActivity(item) => { vec![item.as_legacy_event(self.completed_at_ms)] } + TurnItem::EnteredReviewMode(item) => { + vec![item.as_legacy_event(self.turn_id.clone())] + } + TurnItem::ExitedReviewMode(item) => { + vec![item.as_legacy_event(self.turn_id.clone())] + } _ => self.item.as_legacy_events(show_raw_agent_reasoning), } } diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 636c64ed7b..5115ebcaae 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -30,5 +30,6 @@ pub mod plan_tool; pub mod protocol; pub mod request_permissions; pub mod request_user_input; +pub mod review_format; pub mod shell_environment; pub mod user_input; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b1b845fe28..9eb9cb6de4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1428,7 +1428,7 @@ pub enum EventMsg { ShutdownComplete, /// Entered review mode. - EnteredReviewMode(ReviewRequest), + EnteredReviewMode(EnteredReviewModeEvent), /// Exited review mode with an optional final result to apply. ExitedReviewMode(ExitedReviewModeEvent), @@ -1852,8 +1852,28 @@ pub struct ReasoningRawContentDeltaEvent { pub content_index: i64, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct EnteredReviewModeEvent { + pub target: ReviewTarget, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub user_facing_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub item_id: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExitedReviewModeEvent { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub item_id: Option, pub review_output: Option, } @@ -4313,6 +4333,8 @@ mod tests { use crate::items::CommandExecutionStatus; use crate::items::DynamicToolCallItem; use crate::items::DynamicToolCallStatus; + use crate::items::EnteredReviewModeItem; + use crate::items::ExitedReviewModeItem; use crate::items::FileChangeItem; use crate::items::ImageGenerationItem; use crate::items::McpToolCallItem; @@ -5427,6 +5449,63 @@ mod tests { )); } + #[test] + fn review_mode_item_completion_emits_legacy_events_with_ids() { + let entered = ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + completed_at_ms: 0, + item: TurnItem::EnteredReviewMode(EnteredReviewModeItem { + id: "entered-review".into(), + target: ReviewTarget::Custom { + instructions: "review this".into(), + }, + user_facing_hint: "Review requested.".into(), + }), + }; + let exited = ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + completed_at_ms: 0, + item: TurnItem::ExitedReviewMode(ExitedReviewModeItem { + id: "exited-review".into(), + review_output: Some(ReviewOutputEvent { + overall_explanation: "Looks good.".into(), + ..Default::default() + }), + }), + }; + + assert!(matches!( + entered + .as_legacy_events(/*show_raw_agent_reasoning*/ false) + .as_slice(), + [EventMsg::EnteredReviewMode(EnteredReviewModeEvent { + target: ReviewTarget::Custom { instructions }, + user_facing_hint: Some(user_facing_hint), + turn_id: Some(turn_id), + item_id: Some(item_id), + })] + if instructions == "review this" + && user_facing_hint == "Review requested." + && turn_id == "turn-1" + && item_id == "entered-review" + )); + assert!(matches!( + exited + .as_legacy_events(/*show_raw_agent_reasoning*/ false) + .as_slice(), + [EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + turn_id: Some(turn_id), + item_id: Some(item_id), + review_output: Some(review_output), + })] + if turn_id == "turn-1" + && item_id == "exited-review" + && review_output.overall_explanation == "Looks good." + )); + } + #[test] fn item_started_event_requires_started_at_ms() { let mut value = serde_json::to_value(ItemStartedEvent { @@ -5456,6 +5535,27 @@ mod tests { assert_eq!(event.completed_at_ms, 0); } + #[test] + fn review_mode_events_deserialize_legacy_payloads() { + let entered = serde_json::from_value::(json!({ + "target": { + "type": "custom", + "instructions": "review this" + }, + "user_facing_hint": "hint" + })) + .unwrap(); + assert_eq!(entered.turn_id, None); + assert_eq!(entered.item_id, None); + + let exited = serde_json::from_value::(json!({ + "review_output": null + })) + .unwrap(); + assert_eq!(exited.turn_id, None); + assert_eq!(exited.item_id, None); + } + #[test] fn rollback_failed_error_does_not_affect_turn_status() { let event = ErrorEvent { diff --git a/codex-rs/core/src/review_format.rs b/codex-rs/protocol/src/review_format.rs similarity index 89% rename from codex-rs/core/src/review_format.rs rename to codex-rs/protocol/src/review_format.rs index 2e249df793..6a5d447eea 100644 --- a/codex-rs/core/src/review_format.rs +++ b/codex-rs/protocol/src/review_format.rs @@ -1,8 +1,7 @@ -use codex_protocol::protocol::ReviewFinding; -use codex_protocol::protocol::ReviewOutputEvent; +use crate::protocol::ReviewFinding; +use crate::protocol::ReviewOutputEvent; -// Note: We keep this module UI-agnostic. It returns plain strings that -// higher layers (e.g., TUI) may style as needed. +// These helpers return plain strings that higher layers may style as needed. fn format_location(item: &ReviewFinding) -> String { let path = item.code_location.absolute_file_path.display(); @@ -11,7 +10,8 @@ fn format_location(item: &ReviewFinding) -> String { format!("{path}:{start}-{end}") } -const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; +/// Fallback text used when a review contains no displayable output. +pub const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; /// Format a full review findings block as plain text lines. /// diff --git a/codex-rs/rollout/src/persistence_metrics.rs b/codex-rs/rollout/src/persistence_metrics.rs index e8510a0d93..07d16c1334 100644 --- a/codex-rs/rollout/src/persistence_metrics.rs +++ b/codex-rs/rollout/src/persistence_metrics.rs @@ -256,6 +256,8 @@ fn turn_item_type(item: &TurnItem) -> &'static str { TurnItem::Sleep(_) => "sleep", TurnItem::Extension(_) => "extension", TurnItem::ImageGeneration(_) => "image_generation", + TurnItem::EnteredReviewMode(_) => "entered_review_mode", + TurnItem::ExitedReviewMode(_) => "exited_review_mode", TurnItem::FileChange(_) => "file_change", TurnItem::McpToolCall(_) => "mcp_tool_call", TurnItem::ContextCompaction(_) => "context_compaction", From f73a07224653c2cc775b3f84f129b872b1e08f85 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 8 Jul 2026 10:50:18 -0700 Subject: [PATCH 33/53] test: remove TestAppServer constructors (#31452) ## Why Finish the TestAppServer builder migration after every caller has moved off the compatibility constructors. ## What - remove the obsolete public TestAppServer constructors - leave TestAppServer::builder() as the only fixture construction API ## Validation - cargo check -p codex-app-server --tests ## Cleanup stack 1. [#31425 test: add TestAppServer builder](https://github.com/openai/codex/pull/31425) 2. [#31451 test: migrate TestAppServer callers to builder](https://github.com/openai/codex/pull/31451) 3. this PR --- AGENTS.md | 2 +- .../tests/common/test_app_server.rs | 134 +----------------- .../app-server/tests/suite/v2/app_list.rs | 6 +- 3 files changed, 13 insertions(+), 129 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bdfa798f5c..faa57cc0db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,7 +253,7 @@ Use `just bench-smoke` to dry-run the benchmark for a single iteration to ensure - Tests should exercise app-server's public JSON-RPC API. - Use similar server mocking as for core integration tests. -- Use `TestAppServer::new_with_auto_env()` and `TestAppServer::send_thread_start_request_with_auto_env()` +- Use `TestAppServer::builder().build()` and `TestAppServer::send_thread_start_request_with_auto_env()` by default to ensure that new tests work with foreign app/exec OSes. See `$remote-tests` for details. 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 e080ab0261..c41b2e7c19 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -165,55 +165,19 @@ impl TestAppServer { self.process.wait().await } - pub async fn new(codex_home: &Path) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .build() - .await - } - - /// Starts an app server with the standard test environment and retains it - /// for the server's lifetime. - /// - /// Local test runs explicitly remove `CODEX_EXEC_SERVER_URL`; Docker- and - /// Wine-backed runs set it to the remote fixture URL. Use - /// [`Self::auto_env_params`] or - /// [`Self::send_thread_start_request_with_auto_env`] to select the matching - /// target-native cwd in a thread. Because `environments.toml` overrides the - /// URL-based configuration, this helper rejects a `codex_home` containing - /// that file. - pub async fn new_with_auto_env(codex_home: &Path) -> anyhow::Result { - Self::builder().with_codex_home(codex_home).build().await - } - - /// Starts an auto-environment app server that emits JSON logs. - /// - /// `rust_log` is the value to use for the `RUST_LOG` environment variable. - pub async fn new_with_auto_env_and_json_logging( - codex_home: &Path, - rust_log: impl Into, - ) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .with_json_logging(rust_log) - .build() - .await - } - /// Returns the automatically selected test environment retained by this server. /// /// Tests can use the environment to arrange target-native filesystem fixtures before starting - /// a thread. Returns an error unless this server was created with [`Self::new_with_auto_env`]. + /// a thread. Returns an error unless the builder's automatic environment is enabled. pub fn auto_env(&self) -> anyhow::Result<&TestEnv> { self.auto_env .as_ref() - .context("auto environment is unavailable; use TestAppServer::new_with_auto_env") + .context("auto environment is unavailable; enable it on TestAppServer::builder") } /// Returns app-server protocol parameters for the automatically selected - /// test environment. Returns an error unless this server was created with - /// [`Self::new_with_auto_env`]. + /// test environment. Returns an error unless the builder's automatic + /// environment is enabled. pub fn auto_env_params(&self) -> anyhow::Result { let selection = self.auto_env()?.selection(); Ok(TurnEnvironmentParams { @@ -249,90 +213,6 @@ impl TestAppServer { self.json_logs.events() } - pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .without_managed_config() - .build() - .await - } - - pub async fn new_without_managed_config_with_env( - codex_home: &Path, - env_overrides: &[(&str, Option<&str>)], - ) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .without_managed_config() - .with_env_overrides(env_overrides) - .build() - .await - } - - pub async fn new_with_plugin_startup_tasks(codex_home: &Path) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .with_plugin_startup_tasks() - .build() - .await - } - - pub async fn new_with_env_and_plugin_startup_tasks( - codex_home: &Path, - env_overrides: &[(&str, Option<&str>)], - ) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .with_plugin_startup_tasks() - .with_env_overrides(env_overrides) - .build() - .await - } - - pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .with_args(args) - .build() - .await - } - - /// Creates a new MCP process, allowing tests to override or remove - /// specific environment variables for the child process only. - /// - /// Pass a tuple of (key, Some(value)) to set/override, or (key, None) to - /// remove a variable from the child's environment. - pub async fn new_with_env( - codex_home: &Path, - env_overrides: &[(&str, Option<&str>)], - ) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .with_env_overrides(env_overrides) - .build() - .await - } - - pub async fn new_with_program_and_env( - codex_home: &Path, - program: &Path, - env_overrides: &[(&str, Option<&str>)], - ) -> anyhow::Result { - Self::builder() - .with_codex_home(codex_home) - .without_auto_env() - .with_program(program) - .with_env_overrides(env_overrides) - .build() - .await - } - async fn new_with_program_env_and_args( codex_home: &Path, program: &Path, @@ -579,9 +459,9 @@ impl TestAppServer { self.send_request("thread/start", params).await } - /// Sends a `thread/start` request selecting the environment provisioned by - /// [`Self::new_with_auto_env`]. Returns an error if `params` already select - /// environments so the caller cannot accidentally override the fixture. + /// Sends a `thread/start` request selecting the builder's automatic + /// environment. Returns an error if `params` already select environments + /// so the caller cannot accidentally override the fixture. pub async fn send_thread_start_request_with_auto_env( &mut self, mut params: ThreadStartParams, diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 35c21e4c68..a5e589be42 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -206,7 +206,11 @@ async fn list_apps_uses_external_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; write_connectors_config(codex_home.path(), &server_url)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let login_id = mcp .send_chatgpt_auth_tokens_login_request( From 48cf58233190ce6f2aa6c3194d022c0e273e8fd6 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 8 Jul 2026 18:58:02 +0100 Subject: [PATCH 34/53] Round MCP timeout durations in error messages (#31612) ## Summary MCP operation timeout errors currently print the full debug precision of the remaining timeout budget. That makes a configured 30-second timeout show up as something like `29.999999875s`. This PR rounds the displayed duration to a whole unit, so the error is clean and stable: ```text timed out awaiting tools/list after 30s ``` The timeout behavior itself is unchanged; this only affects the human-facing error text. A regression test covers the reported `tools/list` case. --- codex-rs/rmcp-client/src/rmcp_client.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index c6527990fa..499c8db3bd 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -228,7 +228,7 @@ where enum ClientOperationError { #[error(transparent)] Service(#[from] rmcp::service::ServiceError), - #[error("timed out awaiting {label} after {duration:?}")] + #[error("timed out awaiting {label} after {duration:.0?}")] Timeout { label: String, duration: Duration }, } @@ -1219,6 +1219,16 @@ mod tests { use super::*; + #[test] + fn client_operation_timeout_rounds_duration() { + let error = ClientOperationError::Timeout { + label: "tools/list".to_string(), + duration: Duration::from_nanos(29_999_999_875), + }; + + assert_eq!(error.to_string(), "timed out awaiting tools/list after 30s"); + } + #[tokio::test] async fn active_time_timeout_pauses_while_elicitation_is_pending() { let pause_state = ElicitationPauseState::new(); From 166534fc2278ca951a88cd3841557026edfeddf3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 8 Jul 2026 15:05:57 -0300 Subject: [PATCH 35/53] fix(windows-sandbox): allow deletion in writable roots (#31138) ## Why The legacy unelevated Windows sandbox allowed tools to create and update files in workspace-write roots, but it could not delete files that already existed there. This breaks operations such as `apply_patch` file deletion and replacement in the workspace, `TEMP`, and `TMP`. The delete grant must also preserve deny-write carveouts. Granting `FILE_DELETE_CHILD` on a writable parent would let the sandbox remove protected children such as `.git` or an explicit read-only subpath even when those children have direct deny ACEs. This addresses the delete-failure variant reported in #30009 and #30712. It does not claim to fix their separate split-root setup, elevated-helper, or proxy-related failure modes. ## What Changed - Give writable-root capability ACEs inheritable `DELETE` rights without granting parent-level `FILE_DELETE_CHILD`, so descendants can be removed while protected children remain protected. - Replace stale write ACEs that still contain `FILE_DELETE_CHILD`, and make elevated setup detect and refresh that unsafe legacy state. - Keep read-only capability handling unchanged. - Add Windows regressions covering pre-existing files in the workspace, `TEMP`, and `TMP`, plus protected `.git` and outside-root controls. The core ACL behavior is in [`acl.rs`](https://github.com/openai/codex/blob/767540eec35cc88dafc34d87fcec7e58087413fb/codex-rs/windows-sandbox-rs/src/acl.rs#L303-L438), stale-ACE detection is in [`setup_main/win.rs`](https://github.com/openai/codex/blob/767540eec35cc88dafc34d87fcec7e58087413fb/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs#L163-L179), and the end-to-end regression is in [`unified_exec/tests.rs`](https://github.com/openai/codex/blob/767540eec35cc88dafc34d87fcec7e58087413fb/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs#L458-L568). ## How to Test On Windows: 1. Start Codex with the legacy unelevated Windows sandbox and a workspace-write permission profile. 2. Seed pre-existing files in the workspace, `TEMP`, and `TMP`; also create a sibling file outside the writable roots and a protected `.git` directory. 3. Delete the three files inside writable roots through a sandboxed command or `apply_patch`. 4. Confirm the writable-root files are deleted, while the outside-root file and protected `.git` directory remain intact. Targeted tests: - `just test -p codex-windows-sandbox` - Windows-only `legacy_workspace_write_delete_is_limited_to_writable_roots` - Windows-only `write_root_refresh_replaces_stale_delete_child_grant` The final SHA passed all 31 required checks, including the Windows Bazel test matrix, in [run 28886245161](https://github.com/openai/codex/actions/runs/28886245161). --- codex-rs/windows-sandbox-rs/src/acl.rs | 31 ++++- .../src/bin/setup_main/win.rs | 96 +++++++++++---- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 3 +- .../src/unified_exec/tests.rs | 110 ++++++++++++++++++ 4 files changed, 214 insertions(+), 26 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs index a368371dc4..4b859b047f 100644 --- a/codex-rs/windows-sandbox-rs/src/acl.rs +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -301,19 +301,30 @@ pub unsafe fn dacl_has_read_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -> false } +// Grant DELETE on each inheriting descendant instead of FILE_DELETE_CHILD on +// its parent. A parent delete-child grant would bypass a direct deny-write ACE +// on protected children such as `.git` or an explicit read-only subpath. const WRITE_ALLOW_MASK: u32 = - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD; + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE; unsafe fn ensure_allow_mask_aces_with_inheritance_impl( path: &Path, sids: &[*mut c_void], allow_mask: u32, + disallow_mask: u32, inheritance: u32, ) -> Result { let (p_dacl, p_sd) = fetch_dacl_handle(path)?; let mut entries: Vec = Vec::new(); for sid in sids { - if dacl_mask_allows(p_dacl, &[*sid], allow_mask, /*require_all_bits*/ true) { + if dacl_mask_allows(p_dacl, &[*sid], allow_mask, /*require_all_bits*/ true) + && !dacl_mask_allows( + p_dacl, + &[*sid], + disallow_mask, + /*require_all_bits*/ false, + ) + { continue; } entries.push(EXPLICIT_ACCESS_W { @@ -386,7 +397,13 @@ pub unsafe fn ensure_allow_mask_aces_with_inheritance( allow_mask: u32, inheritance: u32, ) -> Result { - ensure_allow_mask_aces_with_inheritance_impl(path, sids, allow_mask, inheritance) + ensure_allow_mask_aces_with_inheritance_impl( + path, + sids, + allow_mask, + /*disallow_mask*/ 0, + inheritance, + ) } /// Ensure all provided SIDs have an allow ACE with the requested mask on the path. @@ -413,7 +430,13 @@ pub unsafe fn ensure_allow_mask_aces( /// # Safety /// Caller must pass valid SID pointers and an existing path; free the returned security descriptor with `LocalFree`. pub unsafe fn ensure_allow_write_aces(path: &Path, sids: &[*mut c_void]) -> Result { - ensure_allow_mask_aces(path, sids, WRITE_ALLOW_MASK) + ensure_allow_mask_aces_with_inheritance_impl( + path, + sids, + WRITE_ALLOW_MASK, + FILE_DELETE_CHILD, + CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE, + ) } /// Adds an allow ACE granting read/write/execute to the given SID on the target path. diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index e1146987bf..efe9f6af9e 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -65,6 +65,8 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; const DENY_ACCESS: i32 = 3; +const WRITE_ROOT_ALLOW_MASK: u32 = + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE; mod sandbox_users; mod setup_runtime_bin; @@ -158,6 +160,23 @@ fn workspace_write_cap_sids_for_path( Ok(sid_strs) } +fn write_root_needs_refresh(root: &Path, psid: *mut c_void) -> Result { + if !path_mask_allows( + root, + &[psid], + WRITE_ROOT_ALLOW_MASK, + /*require_all_bits*/ true, + )? { + return Ok(true); + } + path_mask_allows( + root, + &[psid], + FILE_DELETE_CHILD, + /*require_all_bits*/ false, + ) +} + fn spawn_read_acl_helper(payload: &Payload, _log: &mut dyn Write) -> Result<()> { let mut read_payload = payload.clone(); read_payload.mode = SetupMode::ReadAclsOnly; @@ -826,8 +845,6 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res )?; } - let write_mask = - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD; let mut grant_tasks: Vec<(PathBuf, String)> = Vec::new(); let mut seen_deny_paths: HashSet = HashSet::new(); @@ -862,27 +879,26 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res ("sandbox_group", sandbox_group_psid), (cap_label, root_cap_psid), ] { - let has = - match path_mask_allows(root, &[psid], write_mask, /*require_all_bits*/ true) { - Ok(h) => h, - Err(e) => { - refresh_errors.push(format!( - "write mask check failed on {} for {label}: {}", + let needs_refresh = match write_root_needs_refresh(root, psid) { + Ok(needs_refresh) => needs_refresh, + Err(e) => { + refresh_errors.push(format!( + "write ACE check failed on {} for {label}: {}", + root.display(), + e + )); + log_line( + log, + &format!( + "write ACE check failed on {} for {label}: {}; continuing", root.display(), e - )); - log_line( - log, - &format!( - "write mask check failed on {} for {label}: {}; continuing", - root.display(), - e - ), - )?; - false - } - }; - if !has { + ), + )?; + true + } + }; + if needs_refresh { need_grant = true; } } @@ -1036,13 +1052,21 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res mod tests { use super::Payload; use super::SETUP_VERSION; + use super::WRITE_ROOT_ALLOW_MASK; + use super::convert_string_sid_to_sid; use super::workspace_write_cap_sids_for_path; + use super::write_root_needs_refresh; use codex_otel::StatsigMetricsSettings; + use codex_windows_sandbox::ensure_allow_mask_aces; + use codex_windows_sandbox::ensure_allow_write_aces; use codex_windows_sandbox::load_or_create_cap_sids; use codex_windows_sandbox::workspace_write_cap_sid_for_root; use pretty_assertions::assert_eq; use serde_json::json; use std::fs; + use windows_sys::Win32::Foundation::HLOCAL; + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Storage::FileSystem::FILE_DELETE_CHILD; fn payload_json() -> serde_json::Value { json!({ @@ -1090,6 +1114,36 @@ mod tests { ); } + #[test] + fn write_root_refresh_replaces_stale_delete_child_grant() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&workspace).expect("create workspace"); + + let sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let psid = unsafe { convert_string_sid_to_sid(&sid).expect("convert workspace sid") }; + let stale_write_mask = WRITE_ROOT_ALLOW_MASK | FILE_DELETE_CHILD; + let seeded = unsafe { ensure_allow_mask_aces(&workspace, &[psid], stale_write_mask) } + .expect("seed stale write ACE"); + let needs_refresh_before = + write_root_needs_refresh(&workspace, psid).expect("check stale write ACE"); + let replaced = unsafe { ensure_allow_write_aces(&workspace, &[psid]) } + .expect("replace stale write ACE"); + let needs_refresh_after = + write_root_needs_refresh(&workspace, psid).expect("check refreshed write ACE"); + unsafe { + LocalFree(psid as HLOCAL); + } + + assert_eq!( + (seeded, needs_refresh_before, replaced, needs_refresh_after), + (true, true, true, false) + ); + } + #[test] fn deny_path_under_active_root_uses_only_matching_root_sid() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index 382a6a4531..9479c0b8d7 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -1,6 +1,7 @@ use crate::acl::add_allow_ace; use crate::acl::add_deny_write_ace; use crate::acl::allow_null_device; +use crate::acl::ensure_allow_write_aces; use crate::allow::AllowDenyPaths; use crate::allow::compute_allow_paths_for_permissions; use crate::cap::load_or_create_cap_sids; @@ -294,7 +295,7 @@ pub(crate) fn apply_legacy_session_acl_rules( let Some(root_sid) = matching_root_capability(p, acl_sids.write_root_sids) else { continue; }; - let _ = add_allow_ace(p, root_sid.sid.as_ptr()); + let _ = ensure_allow_write_aces(p, &[root_sid.sid.as_ptr()]); } } for p in &deny { diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs index 96f5c39e92..12159ec991 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs @@ -454,6 +454,116 @@ fn legacy_capture_powershell_emits_output() { ); } +#[test] +fn legacy_workspace_write_delete_is_limited_to_writable_roots() { + let _guard = legacy_process_test_guard(); + let runtime = current_thread_runtime(); + runtime.block_on(async move { + // Keep writable roots out of USERPROFILE exclusions such as AppData. + let test_root = TempDir::new_in(sandbox_cwd()).expect("create legacy delete test root"); + let codex_home = sandbox_home("legacy-delete-writable-roots"); + let workspace = test_root.path().join("workspace"); + let temp_root = test_root.path().join("temp"); + let tmp_root = test_root.path().join("tmp"); + let outside_root = test_root.path().join("outside"); + for directory in [&workspace, &temp_root, &tmp_root, &outside_root] { + fs::create_dir_all(directory).expect("create legacy delete test directory"); + } + let protected_git_dir = workspace.join(".git"); + fs::create_dir(&protected_git_dir).expect("create protected .git directory"); + + let workspace_file = workspace.join("workspace-delete.txt"); + let temp_file = temp_root.join("temp-delete.txt"); + let tmp_file = tmp_root.join("tmp-delete.txt"); + let outside_file = outside_root.join("outside-delete.txt"); + fs::write(&workspace_file, "workspace").expect("seed workspace file"); + fs::write(&temp_file, "temp").expect("seed TEMP file"); + fs::write(&tmp_file, "tmp").expect("seed TMP file"); + fs::write(&outside_file, "outside").expect("seed outside file"); + + let script = workspace.join("delete-fixtures.cmd"); + fs::write( + &script, + concat!( + "@echo off\r\n", + "del /f /q \"%WORKSPACE_DELETE%\"\r\n", + "del /f /q \"%TEMP_DELETE%\"\r\n", + "del /f /q \"%TMP_DELETE%\"\r\n", + "del /f /q \"%OUTSIDE_DELETE%\"\r\n", + "rmdir \"%PROTECTED_GIT_DIR%\"\r\n", + "exit /b 0\r\n", + ), + ) + .expect("write delete script"); + + let env_map = HashMap::from([ + ("TEMP".to_string(), temp_root.to_string_lossy().into_owned()), + ("TMP".to_string(), tmp_root.to_string_lossy().into_owned()), + ( + "WORKSPACE_DELETE".to_string(), + workspace_file.to_string_lossy().into_owned(), + ), + ( + "TEMP_DELETE".to_string(), + temp_file.to_string_lossy().into_owned(), + ), + ( + "TMP_DELETE".to_string(), + tmp_file.to_string_lossy().into_owned(), + ), + ( + "OUTSIDE_DELETE".to_string(), + outside_file.to_string_lossy().into_owned(), + ), + ( + "PROTECTED_GIT_DIR".to_string(), + protected_git_dir.to_string_lossy().into_owned(), + ), + ]); + + let permission_profile = PermissionProfile::workspace_write(); + let spawned = spawn_windows_sandbox_session_legacy( + &permission_profile, + workspace_roots_for(workspace.as_path()).as_slice(), + codex_home.path(), + vec![ + "C:\\Windows\\System32\\cmd.exe".to_string(), + "/d".to_string(), + "/c".to_string(), + script.display().to_string(), + ], + workspace.as_path(), + env_map, + /*timeout_ms*/ Some(5_000), + &[], + &[], + /*tty*/ false, + /*stdin_open*/ false, + /*use_private_desktop*/ true, + ) + .await + .expect("spawn legacy delete session"); + let (stdout, exit_code) = + collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(/*secs*/ 10)) + .await; + let stdout = String::from_utf8_lossy(&stdout); + + assert_eq!( + ( + exit_code, + workspace_file.exists(), + temp_file.exists(), + tmp_file.exists(), + fs::read_to_string(&outside_file).ok(), + protected_git_dir.is_dir(), + ), + (0, false, false, false, Some("outside".to_string()), true), + "stdout={stdout:?}\n{}", + sandbox_log(codex_home.path()) + ); + }); +} + #[test] fn legacy_capture_cancellation_is_not_reported_as_timeout() { let Some(pwsh) = pwsh_path() else { From 9c6715924b4ce273248d03107383defc171a9a58 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Wed, 8 Jul 2026 11:06:58 -0700 Subject: [PATCH 36/53] code-mode: move to hosted mode by default (#31500) ## Summary - Promote code_mode_host to stable and enable it by default. - Preserve features.code_mode_host = false as an opt-out to the in-process runtime. - Run core code-mode tests through the standalone host. - Keep explicit coverage for missing-host failures. --- codex-rs/app-server/BUILD.bazel | 1 + .../tests/common/test_app_server.rs | 15 ++++ codex-rs/core/BUILD.bazel | 1 + codex-rs/core/src/test_support.rs | 7 ++ codex-rs/core/src/thread_manager.rs | 10 +++ codex-rs/core/tests/common/test_codex.rs | 21 +++++ codex-rs/core/tests/suite/code_mode.rs | 28 ++++-- codex-rs/features/src/lib.rs | 4 +- codex-rs/features/src/tests.rs | 10 +++ defs.bzl | 3 +- workspace_root_test_launcher.bat.tpl | 89 +++++++++++++------ 11 files changed, 153 insertions(+), 36 deletions(-) diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index a8e7f50271..e5ef8268e5 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -5,6 +5,7 @@ codex_rust_crate( crate_name = "codex_app_server", extra_binaries = [ "//codex-rs/bwrap:bwrap", + "//codex-rs/code-mode-host:codex-code-mode-host", ], extra_binaries_non_windows = [ "//codex-rs/cli:codex", 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 c41b2e7c19..1a74e1df5a 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -147,6 +147,7 @@ pub struct TestAppServer { pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tasks-for-tests"; const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; +const CODE_MODE_HOST_PATH_ENV_VAR: &str = "CODEX_CODE_MODE_HOST_PATH"; impl TestAppServer { /// Starts building a server with a temporary CODEX_HOME and the standard @@ -1819,6 +1820,20 @@ impl TestAppServerBuilder { } TestAppServerEnvironment::None => None, }; + if !env_overrides + .iter() + .any(|(key, _)| key == CODE_MODE_HOST_PATH_ENV_VAR) + && let Ok(code_mode_host_program) = + codex_utils_cargo_bin::cargo_bin("codex-code-mode-host") + { + env_overrides.insert( + 0, + ( + CODE_MODE_HOST_PATH_ENV_VAR.to_string(), + Some(code_mode_host_program.to_string_lossy().into_owned()), + ), + ); + } let program = match program { Some(program) => program, None => codex_utils_cargo_bin::cargo_bin("codex-app-server") diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index a55a98e186..a3f0583b80 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -14,6 +14,7 @@ codex_rust_crate( crate_name = "codex_core", extra_binaries = [ "//codex-rs/bwrap:bwrap", + "//codex-rs/code-mode-host:codex-code-mode-host", "//codex-rs/linux-sandbox:codex-linux-sandbox", "//codex-rs/rmcp-client:test_stdio_server", "//codex-rs/rmcp-client:test_streamable_http_server", diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 1db714fada..1f946602f5 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -71,6 +71,13 @@ pub fn auth_manager_from_auth_with_home(auth: CodexAuth, codex_home: PathBuf) -> AuthManager::from_auth_for_testing_with_home(auth, codex_home) } +pub fn with_code_mode_host_program( + thread_manager: ThreadManager, + host_program: PathBuf, +) -> ThreadManager { + thread_manager.with_code_mode_host_program_for_tests(host_program) +} + pub fn thread_manager_with_models_provider( auth: CodexAuth, provider: ModelProviderInfo, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 4ea86cb33d..669da49547 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -364,6 +364,16 @@ impl ThreadManager { } } + pub(crate) fn with_code_mode_host_program_for_tests(mut self, host_program: PathBuf) -> Self { + let Some(state) = Arc::get_mut(&mut self.state) else { + unreachable!("new thread manager state should not be shared"); + }; + state.code_mode_session_provider = Arc::new( + ProcessOwnedCodeModeSessionProvider::with_host_program(host_program), + ); + self + } + /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub(crate) fn with_models_provider_for_tests( diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index e0e82c37fd..62624d9e3c 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -290,6 +290,7 @@ pub struct TestCodexBuilder { user_instructions_provider: Option>, supports_openai_form_elicitation: bool, external_time_provider: Option>, + code_mode_host_program: Option, } impl TestCodexBuilder { @@ -396,6 +397,11 @@ impl TestCodexBuilder { self } + pub fn with_code_mode_host_program(mut self, host_program: PathBuf) -> Self { + self.code_mode_host_program = Some(host_program); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -613,6 +619,20 @@ impl TestCodexBuilder { /*attestation_provider*/ None, /*external_time_provider*/ self.external_time_provider.clone(), ); + let code_mode_host_program = self + .code_mode_host_program + .take() + .or_else(|| codex_utils_cargo_bin::cargo_bin("codex-code-mode-host").ok()); + let thread_manager = if config.features.enabled(Feature::CodeModeHost) + && let Some(code_mode_host_program) = code_mode_host_program + { + codex_core::test_support::with_code_mode_host_program( + thread_manager, + code_mode_host_program, + ) + } else { + thread_manager + }; let thread_manager = Arc::new(thread_manager); let user_shell_override = self.user_shell_override.clone(); @@ -1219,6 +1239,7 @@ pub fn test_codex() -> TestCodexBuilder { user_instructions_provider: None, supports_openai_form_elicitation: false, external_time_provider: None, + code_mode_host_program: None, } } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9293b506ef..8d56fae7d0 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -43,6 +43,7 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_wine_exec; use core_test_support::stdio_server_bin; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -190,10 +191,19 @@ async fn run_code_mode_turn_with_model_and_config( model: &'static str, configure: impl FnOnce(&mut Config) + Send + 'static, ) -> Result<(TestCodex, ResponseMock)> { - let mut builder = test_codex().with_model(model).with_config(move |config| { + let builder = test_codex().with_model(model).with_config(move |config| { let _ = config.features.enable(Feature::CodeMode); configure(config); }); + run_code_mode_turn_with_builder(server, prompt, code, builder).await +} + +async fn run_code_mode_turn_with_builder( + server: &MockServer, + prompt: &str, + code: &str, + mut builder: TestCodexBuilder, +) -> Result<(TestCodex, ResponseMock)> { let test = builder.build(server).await?; responses::mount_sse_once( @@ -224,14 +234,18 @@ async fn missing_process_host_returns_a_tool_error() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, follow_up_mock) = - run_code_mode_turn_with_config(&server, "Run code mode", "text('unreachable')", |config| { + let builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_code_mode_host_program("codex-code-mode-host-does-not-exist".into()) + .with_config(|config| { config .features - .enable(Feature::CodeModeHost) - .expect("code mode host should be enabled"); - }) - .await?; + .enable(Feature::CodeMode) + .expect("code mode should be enabled"); + }); + let (_test, follow_up_mock) = + run_code_mode_turn_with_builder(&server, "Run code mode", "text('unreachable')", builder) + .await?; let output = follow_up_mock .single_request() diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 166dc912fd..bc6cffe2bf 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -857,8 +857,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::CodeModeHost, key: "code_mode_host", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::CodeModeOnly, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7291c4e1b1..3a627a3f81 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -123,6 +123,16 @@ fn code_mode_only_requires_code_mode() { assert_eq!(features.enabled(Feature::CodeMode), true); } +#[test] +fn code_mode_host_is_stable_and_enabled_by_default() { + assert_eq!(Feature::CodeModeHost.stage(), Stage::Stable); + assert_eq!(Feature::CodeModeHost.default_enabled(), true); + assert_eq!( + feature_for_key("code_mode_host"), + Some(Feature::CodeModeHost) + ); +} + #[test] fn guardian_approval_is_stable_and_enabled_by_default() { let spec = Feature::GuardianApproval.info(); diff --git a/defs.bzl b/defs.bzl index 8532a223eb..8eb70ed9e4 100644 --- a/defs.bzl +++ b/defs.bzl @@ -110,8 +110,9 @@ def _windows_runfile_env_exports(ctx): lines = [] for runfile_dep, env_var in ctx.attr.runfile_env.items(): runfile = _runfile_env_file(runfile_dep) - lines.append('call :resolve_runfile {} "{}"'.format(env_var, _runfile_logical_path(runfile))) + lines.append('call :resolve_runfile "{}"'.format(_runfile_logical_path(runfile))) lines.append("if errorlevel 1 exit /b 1") + lines.append('set "{}=!resolve_runfile_result!"'.format(env_var)) return "\n".join(lines) def _runfile_env_file(target): diff --git a/workspace_root_test_launcher.bat.tpl b/workspace_root_test_launcher.bat.tpl index 3613b91d76..fa18c301dd 100644 --- a/workspace_root_test_launcher.bat.tpl +++ b/workspace_root_test_launcher.bat.tpl @@ -1,17 +1,24 @@ @echo off setlocal EnableExtensions EnableDelayedExpansion -call :resolve_runfile workspace_root_marker "__WORKSPACE_ROOT_MARKER__" +call :resolve_runfile "__WORKSPACE_ROOT_MARKER__" if errorlevel 1 exit /b 1 +set "workspace_root_marker=!resolve_runfile_result!" for %%I in ("%workspace_root_marker%") do set "workspace_root_marker_dir=%%~dpI" for %%I in ("%workspace_root_marker_dir%..\..") do set "workspace_root=%%~fI" -call :resolve_runfile test_bin "__TEST_BIN__" +call :resolve_runfile "__TEST_BIN__" if errorlevel 1 exit /b 1 +set "test_bin=!resolve_runfile_result!" __RUNFILE_ENV_EXPORTS__ +if not defined test_bin ( + >&2 echo resolved test binary was lost while exporting runfile environment variables + exit /b 1 +) + __WORKSPACE_ROOT_SETUP__ set "TOTAL_SHARDS=%RULES_RUST_TEST_TOTAL_SHARDS%" @@ -97,44 +104,74 @@ rmdir /s /q "!TEMP_DIR!" 2>nul exit /b !TEST_EXIT! :resolve_runfile -setlocal EnableExtensions EnableDelayedExpansion -set "logical_path=%~2" -set "workspace_logical_path=%logical_path%" -if defined TEST_WORKSPACE set "workspace_logical_path=%TEST_WORKSPACE%/%logical_path%" -set "native_logical_path=%logical_path:/=\%" -set "native_workspace_logical_path=%workspace_logical_path:/=\%" +set "resolve_runfile_result=" +set "resolve_runfile_logical_path=%~1" +set "resolve_runfile_workspace_logical_path=!resolve_runfile_logical_path!" +if defined TEST_WORKSPACE set "resolve_runfile_workspace_logical_path=%TEST_WORKSPACE%/!resolve_runfile_logical_path!" +set "resolve_runfile_native_logical_path=!resolve_runfile_logical_path:/=\!" +set "resolve_runfile_native_workspace_logical_path=!resolve_runfile_workspace_logical_path:/=\!" for %%R in ("%RUNFILES_DIR%" "%TEST_SRCDIR%") do ( - set "runfiles_root=%%~R" - if defined runfiles_root ( - if exist "!runfiles_root!\!native_logical_path!" ( - endlocal & set "%~1=!runfiles_root!\!native_logical_path!" & exit /b 0 + set "resolve_runfile_root=%%~R" + if defined resolve_runfile_root ( + if exist "!resolve_runfile_root!\!resolve_runfile_native_logical_path!" ( + set "resolve_runfile_result=!resolve_runfile_root!\!resolve_runfile_native_logical_path!" + goto :resolve_runfile_success ) - if exist "!runfiles_root!\!native_workspace_logical_path!" ( - endlocal & set "%~1=!runfiles_root!\!native_workspace_logical_path!" & exit /b 0 + if exist "!resolve_runfile_root!\!resolve_runfile_native_workspace_logical_path!" ( + set "resolve_runfile_result=!resolve_runfile_root!\!resolve_runfile_native_workspace_logical_path!" + goto :resolve_runfile_success ) ) ) -set "manifest=%RUNFILES_MANIFEST_FILE%" -if not defined manifest if exist "%~f0.runfiles_manifest" set "manifest=%~f0.runfiles_manifest" -if not defined manifest if exist "%~dpn0.runfiles_manifest" set "manifest=%~dpn0.runfiles_manifest" -if not defined manifest if exist "%~f0.exe.runfiles_manifest" set "manifest=%~f0.exe.runfiles_manifest" +set "resolve_runfile_manifest=%RUNFILES_MANIFEST_FILE%" +if not defined resolve_runfile_manifest if exist "%~f0.runfiles_manifest" set "resolve_runfile_manifest=%~f0.runfiles_manifest" +if not defined resolve_runfile_manifest if exist "%~dpn0.runfiles_manifest" set "resolve_runfile_manifest=%~dpn0.runfiles_manifest" +if not defined resolve_runfile_manifest if exist "%~f0.exe.runfiles_manifest" set "resolve_runfile_manifest=%~f0.exe.runfiles_manifest" -if defined manifest if exist "%manifest%" ( +if defined resolve_runfile_manifest if exist "!resolve_runfile_manifest!" ( rem Read the manifest directly instead of shelling out to findstr. In the rem GitHub Windows runner, the nested `findstr` path produced rem `FINDSTR: Cannot open D:MANIFEST`, which then broke runfile resolution for rem Bazel tests even though the manifest file was present. - for /f "usebackq tokens=1,* delims= " %%A in ("%manifest%") do ( - if "%%A"=="%logical_path%" ( - endlocal & set "%~1=%%B" & exit /b 0 + rem A one-field manifest entry maps to itself, so fall back to %%A when the + rem optional mapped path in %%B is empty. + for /f "usebackq tokens=1,* delims= " %%A in ("!resolve_runfile_manifest!") do ( + if "%%A"=="!resolve_runfile_logical_path!" ( + set "resolve_runfile_manifest_path=%%B" + if not defined resolve_runfile_manifest_path set "resolve_runfile_manifest_path=%%A" + set "resolve_runfile_result=!resolve_runfile_manifest_path!" + goto :resolve_runfile_success ) - if "%%A"=="%workspace_logical_path%" ( - endlocal & set "%~1=%%B" & exit /b 0 + if "%%A"=="!resolve_runfile_workspace_logical_path!" ( + set "resolve_runfile_manifest_path=%%B" + if not defined resolve_runfile_manifest_path set "resolve_runfile_manifest_path=%%A" + set "resolve_runfile_result=!resolve_runfile_manifest_path!" + goto :resolve_runfile_success ) ) ) ->&2 echo failed to resolve runfile: %logical_path% -endlocal & exit /b 1 +>&2 echo failed to resolve runfile: !resolve_runfile_logical_path! +call :clear_resolve_runfile_state +exit /b 1 + +:resolve_runfile_success +if not defined resolve_runfile_result ( + >&2 echo resolved runfile has an empty path: !resolve_runfile_logical_path! + call :clear_resolve_runfile_state + exit /b 1 +) +call :clear_resolve_runfile_state +exit /b 0 + +:clear_resolve_runfile_state +set "resolve_runfile_logical_path=" +set "resolve_runfile_workspace_logical_path=" +set "resolve_runfile_native_logical_path=" +set "resolve_runfile_native_workspace_logical_path=" +set "resolve_runfile_root=" +set "resolve_runfile_manifest=" +set "resolve_runfile_manifest_path=" +exit /b 0 From 9077300d7f05bf0f45212f43c87feafffead10b0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 8 Jul 2026 11:10:35 -0700 Subject: [PATCH 37/53] tui: sanitize terminal controls in user messages (#31494) ## Why A pasted user message can contain a raw CSI sequence that corrupts terminal scrollback, making the latest response or input appear missing. The same sequence may already be present in persisted history when an affected conversation is resumed. ## What changed - Remove CSI sequences and non-whitespace control characters from explicit paste input before it enters the composer. - Apply the same small transform before rich and raw `UserHistoryCell` rendering, so existing conversations render safely without mutating stored message content. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 10 ++++-- codex-rs/tui/src/history_cell/messages.rs | 33 +++++++++++++++---- ...wraps_and_prefixes_each_line_snapshot.snap | 9 ++--- codex-rs/tui/src/history_cell/tests.rs | 3 +- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 76a5760658..b07e839453 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -211,6 +211,7 @@ use super::slash_commands::BuiltinCommandFlags; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandItem; use crate::bottom_pane::paste_burst::FlushResult; +use crate::history_cell::sanitize_user_text; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; @@ -887,6 +888,7 @@ impl ChatComposer { /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); + let pasted = sanitize_user_text(&pasted); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -7559,15 +7561,17 @@ mod tests { /*disable_paste_burst*/ false, ); - let needs_redraw = composer.handle_paste("hello".to_string()); + let sanitized = "_count_rows\tindent\ntwo"; + let needs_redraw = + composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string()); assert!(needs_redraw); - assert_eq!(composer.draft.textarea.text(), "hello"); + assert_eq!(composer.draft.textarea.text(), sanitized); assert!(composer.draft.pending_pastes.is_empty()); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, sanitized), _ => panic!("expected Submitted"), } } diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 787f999cf1..350ae81db1 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -11,6 +11,20 @@ pub(crate) struct UserHistoryCell { pub remote_image_urls: Vec, } +/// Remove CSI sequences and control characters, preserving tabs and newlines. +pub(crate) fn sanitize_user_text(text: &str) -> String { + let mut sanitized = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' && chars.next_if_eq(&'[').is_some() { + let _ = chars.find(|ch| ('@'..='~').contains(ch)); + } else if matches!(ch, '\n' | '\t') || !ch.is_control() { + sanitized.push(ch); + } + } + sanitized +} + /// Build logical lines for a user message with styled text elements. /// /// This preserves explicit newlines while interleaving element spans and skips @@ -93,6 +107,12 @@ fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { + let message = sanitize_user_text(&self.message); + let text_elements = if message == self.message { + self.text_elements.as_slice() + } else { + &[] + }; let wrap_width = width .saturating_sub( LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ @@ -117,10 +137,10 @@ impl HistoryCell for UserHistoryCell { )) }; - let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { + let wrapped_message = if message.is_empty() && text_elements.is_empty() { None - } else if self.text_elements.is_empty() { - let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); + } else if text_elements.is_empty() { + let message_without_trailing_newlines = message.trim_end_matches(['\r', '\n']); let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') @@ -133,8 +153,8 @@ impl HistoryCell for UserHistoryCell { (!wrapped.is_empty()).then_some(wrapped) } else { let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, + &message, + text_elements, style, element_style, ); @@ -177,7 +197,8 @@ impl HistoryCell for UserHistoryCell { } fn raw_lines(&self) -> Vec> { - let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); + let message = sanitize_user_text(&self.message); + let mut lines = raw_lines_from_source(message.trim_end_matches(['\r', '\n'])); if !self.remote_image_urls.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap index 51ce9e312f..beaad7984d 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap @@ -1,9 +1,6 @@ --- -source: tui/src/history_cell.rs +source: tui/src/history_cell/tests.rs expression: rendered --- - -› one two - three - four five - six seven +› _count_ro + ws diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index f5a3a2efa2..b5c9739239 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1978,7 +1978,7 @@ fn ran_cell_multiline_with_stderr_snapshot() { } #[test] fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { - let msg = "one two three four five six seven"; + let msg = "_count_r\x1b[13;2:3uows"; let cell = UserHistoryCell { message: msg.to_string(), text_elements: Vec::new(), @@ -1991,6 +1991,7 @@ fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { let lines = cell.display_lines(width); let rendered = render_lines(&lines).join("\n"); + assert_eq!(render_lines(&cell.raw_lines()), ["_count_rows"]); insta::assert_snapshot!(rendered); } From 154433c8f0e0a98131a92699e761cf34b30344ce Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 8 Jul 2026 11:35:42 -0700 Subject: [PATCH 38/53] chore(protocol): use UUIDv7 for generated item IDs (#31524) ## Description Use UUIDv7 for item IDs generated locally in `codex-protocol`. This covers user messages, agent messages, hook prompts, and context compaction items. These IDs are generated once and carried through item lifecycle, so this only changes the UUID version. It keeps generated item IDs consistent with thread and turn IDs. --- codex-rs/protocol/src/items.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 8fb2e4bb99..a65bd970df 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -409,11 +409,13 @@ pub struct ContextCompactionItem { pub id: String, } +fn new_item_id() -> String { + uuid::Uuid::now_v7().to_string() +} + impl ContextCompactionItem { pub fn new() -> Self { - Self { - id: uuid::Uuid::new_v4().to_string(), - } + Self { id: new_item_id() } } } @@ -426,7 +428,7 @@ impl Default for ContextCompactionItem { impl UserMessageItem { pub fn new(content: &[UserInput]) -> Self { Self { - id: uuid::Uuid::new_v4().to_string(), + id: new_item_id(), client_id: None, content: content.to_vec(), } @@ -528,9 +530,7 @@ fn trim_trailing_default_image_details( impl HookPromptItem { pub fn from_fragments(id: Option<&String>, fragments: Vec) -> Self { Self { - id: id - .cloned() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + id: id.cloned().unwrap_or_else(new_item_id), fragments, } } @@ -560,7 +560,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option Option Self { Self { - id: uuid::Uuid::new_v4().to_string(), + id: new_item_id(), content: content.to_vec(), phase: None, memory_citation: None, From 8d83687aaa356f4102a0130ecddb7ea518c0200c Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Wed, 8 Jul 2026 11:38:18 -0700 Subject: [PATCH 39/53] Fall back to HTTP when Apple Git is unavailable (#31496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - on macOS, resolve the curated plugin sync Git executable without executing it - treat Apple’s `/usr/bin/git` shim as unavailable when `xcode-select -p` reports that developer tools are absent - skip directly to the existing GitHub HTTP fallback in that case - preserve the original `git` command lookup on Windows and Linux, including CCA ## Root cause Curated plugin startup sync invokes `git ls-remote` before its HTTP fallback. On a clean Mac, `git` resolves to Apple’s `/usr/bin/git` shim, and executing the shim opens the Xcode Command Line Tools installer before the process can fail and reach HTTP. On macOS, this change resolves Git through `PATH` without executing it. If the selected binary is Apple’s shim and developer tools are unavailable, startup sync marks the Git transport unavailable and enters the existing HTTP fallback immediately. The new availability detection is macOS-only by construction. Windows and Linux still execute the literal `git` command as before. If Git is missing on Windows, the existing spawn-error path falls back to HTTP; Linux/CCA receives no new lookup or startup behavior. ## Eager Git audit I also audited production Git process spawns in `codex-rs`. - This curated catalog sync is the only default projectless app-server startup path found. - Configured Git marketplace auto-upgrade runs Git at plugin startup, but only after a user has explicitly configured a Git marketplace. - Experimental Memories has background Git metadata/baseline paths when the feature is enabled. - The separate cloud-tasks UI probes Git during environment autodetection. - Normal thread/turn Git metadata is gated by filesystem discovery of an existing `.git` entry. - Marketplace add/install, patch apply, doctor, and TUI `/diff` paths are explicitly user-invoked. ## Validation - `just fmt` - `just bazel-lock-update` — succeeded with no lockfile delta - `just test -p codex-core-plugins` — 313 passed - `just fix -p codex-core-plugins` — completed; emitted one pre-existing unrelated `large_enum_variant` warning in `manifest.rs` - `git diff --check` --- codex-rs/Cargo.lock | 1 + codex-rs/core-plugins/Cargo.toml | 3 + codex-rs/core-plugins/src/startup_sync.rs | 66 ++++++++++--- .../core-plugins/src/startup_sync_tests.rs | 93 ++++++++++++++++--- 4 files changed, 134 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b80e8bc9d0..5aba59500c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2805,6 +2805,7 @@ dependencies = [ "tracing-subscriber", "tracing-test", "url", + "which 8.0.0", "wiremock", "zip 2.4.2", ] diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 81cfe2f89c..e85d16a126 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -50,6 +50,9 @@ tracing = { workspace = true } url = { workspace = true } zip = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +which = { workspace = true } + [dev-dependencies] libc = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index eb7813f35e..0dd437af8a 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -93,9 +93,17 @@ fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf { } pub fn sync_openai_plugins_repo(codex_home: &Path) -> Result { + #[cfg(target_os = "macos")] + let git_binary = match which::which("git") { + Ok(git_path) => macos_git_binary_from_path(git_path, apple_developer_tools_available()), + Err(_) => None, + }; + #[cfg(not(target_os = "macos"))] + let git_binary = Some(PathBuf::from("git")); + sync_openai_plugins_repo_with_transport_overrides( codex_home, - "git", + git_binary.as_deref(), GITHUB_API_BASE_URL, CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL, ) @@ -103,13 +111,18 @@ pub fn sync_openai_plugins_repo(codex_home: &Path) -> Result { fn sync_openai_plugins_repo_with_transport_overrides( codex_home: &Path, - git_binary: &str, + git_binary: Option<&Path>, api_base_url: &str, backup_archive_api_url: &str, ) -> Result { let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; - match sync_openai_plugins_repo_via_git(codex_home, git_binary) { + let git_sync_result = match git_binary { + Some(git_binary) => sync_openai_plugins_repo_via_git(codex_home, git_binary), + None => Err("git executable is unavailable".to_string()), + }; + + match git_sync_result { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("git", "success"); emit_curated_plugins_startup_sync_final_metric("git", "success"); @@ -119,7 +132,6 @@ fn sync_openai_plugins_repo_with_transport_overrides( emit_curated_plugins_startup_sync_metric("git", "failure"); warn!( error = %err, - git_binary, "git sync failed for curated plugin sync; falling back to GitHub HTTP" ); match sync_openai_plugins_repo_via_http(codex_home, api_base_url) { @@ -182,7 +194,10 @@ fn lock_curated_plugins_startup_sync(codex_home: &Path) -> Result Ok(lock_file) } -fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { +fn sync_openai_plugins_repo_via_git( + codex_home: &Path, + git_binary: &Path, +) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); let remote_sha = git_ls_remote_head_sha(git_binary)?; @@ -229,7 +244,7 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu fn fetch_curated_plugins_commit( repo_path: &Path, remote_sha: &str, - git_binary: &str, + git_binary: &Path, ) -> Result<(), String> { fetch_curated_plugins_commit_from( repo_path, @@ -244,7 +259,7 @@ fn fetch_curated_plugins_commit_from_source( repo_path: &Path, source_repo_path: &Path, remote_sha: &str, - git_binary: &str, + git_binary: &Path, ) -> Result<(), String> { fetch_curated_plugins_commit_from( repo_path, @@ -259,7 +274,7 @@ fn fetch_curated_plugins_commit_from( repo_path: &Path, source: &Path, source_revision: &str, - git_binary: &str, + git_binary: &Path, context: &str, ) -> Result<(), String> { let fetch_refspec = format!("+{source_revision}:{CURATED_PLUGINS_FETCH_REF}"); @@ -274,7 +289,7 @@ fn fetch_curated_plugins_commit_from( ensure_git_success(&output, context) } -fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result<(), String> { +fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &Path) -> Result<(), String> { run_git_in_repo( repo_path, git_binary, @@ -291,7 +306,7 @@ fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result< fn run_git_in_repo( repo_path: &Path, - git_binary: &str, + git_binary: &Path, args: &[&str], context: &str, ) -> Result<(), String> { @@ -583,7 +598,7 @@ fn write_curated_plugins_sha(sha_path: &Path, remote_sha: &str) -> Result<(), St fn read_local_git_or_sha_file( repo_path: &Path, sha_path: &Path, - git_binary: &str, + git_binary: &Path, ) -> Option { if repo_path.join(".git").is_dir() && let Ok(sha) = git_head_sha(repo_path, git_binary) @@ -594,7 +609,7 @@ fn read_local_git_or_sha_file( read_sha_file(sha_path) } -fn git_ls_remote_head_sha(git_binary: &str) -> Result { +fn git_ls_remote_head_sha(git_binary: &Path) -> Result { let mut command = git_command(git_binary); command .arg("ls-remote") @@ -622,7 +637,7 @@ fn git_ls_remote_head_sha(git_binary: &str) -> Result { Ok(sha.to_string()) } -fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result { +fn git_head_sha(repo_path: &Path, git_binary: &Path) -> Result { let output = git_command(git_binary) .arg("-C") .arg(repo_path) @@ -647,7 +662,7 @@ fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result { Ok(sha) } -fn git_command(git_binary: &str) -> Command { +fn git_command(git_binary: &Path) -> Command { let mut command = Command::new(git_binary); command.env("GIT_OPTIONAL_LOCKS", "0"); for name in REPOSITORY_LOCAL_GIT_ENVIRONMENT_VARIABLES { @@ -656,6 +671,29 @@ fn git_command(git_binary: &str) -> Command { command } +#[cfg(any(target_os = "macos", test))] +fn macos_git_binary_from_path( + git_path: PathBuf, + apple_developer_tools_available: bool, +) -> Option { + if git_path == Path::new("/usr/bin/git") && !apple_developer_tools_available { + None + } else { + Some(git_path) + } +} + +#[cfg(target_os = "macos")] +fn apple_developer_tools_available() -> bool { + Command::new("/usr/bin/xcode-select") + .arg("-p") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + fn run_git_command_with_timeout( command: &mut Command, context: &str, diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index fcedf30842..a4ea778443 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -19,7 +19,7 @@ const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567" #[test] fn git_command_sanitizes_ambient_repository_environment() { - let command = git_command("git"); + let command = git_command(Path::new("git")); for name in REPOSITORY_LOCAL_GIT_ENVIRONMENT_VARIABLES { assert_eq!( @@ -190,9 +190,29 @@ async fn run_sync_with_transport_overrides( let api_base_url = api_base_url.into(); let backup_archive_api_url = backup_archive_api_url.into(); tokio::task::spawn_blocking(move || { + let git_binary = PathBuf::from(git_binary); sync_openai_plugins_repo_with_transport_overrides( codex_home.as_path(), - &git_binary, + Some(git_binary.as_path()), + &api_base_url, + &backup_archive_api_url, + ) + }) + .await + .expect("sync task should join") +} + +async fn run_sync_without_git( + codex_home: PathBuf, + api_base_url: impl Into, + backup_archive_api_url: impl Into, +) -> Result { + let api_base_url = api_base_url.into(); + let backup_archive_api_url = backup_archive_api_url.into(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_transport_overrides( + codex_home.as_path(), + /*git_binary*/ None, &api_base_url, &backup_archive_api_url, ) @@ -343,7 +363,7 @@ exit 1 barrier.wait(); sync_openai_plugins_repo_with_transport_overrides( tmp.path(), - git_path.to_str().expect("utf8 path"), + Some(git_path.as_path()), "http://127.0.0.1:9", "http://127.0.0.1:9/backend-api/plugins/export/curated", ) @@ -463,9 +483,8 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { ), ); - let synced_sha = - sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) - .expect("git sync should succeed"); + let synced_sha = sync_openai_plugins_repo_via_git(tmp.path(), &git_wrapper) + .expect("git sync should succeed"); assert_eq!(synced_sha, sha); assert_curated_gmail_repo(&curated_plugins_repo_path(tmp.path())); @@ -517,9 +536,8 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .trim() .to_string(); - let synced_sha = - sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) - .expect("incremental git sync should succeed"); + let synced_sha = sync_openai_plugins_repo_via_git(tmp.path(), &git_wrapper) + .expect("incremental git sync should succeed"); assert_eq!(synced_sha, updated_sha); assert!( @@ -573,9 +591,8 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { assert!(!has_plugins_clone_dirs(tmp.path())); let unchanged_sync_invocation_count = invocation_log_contents.lines().count(); - let synced_sha = - sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) - .expect("unchanged git sync should succeed"); + let synced_sha = sync_openai_plugins_repo_via_git(tmp.path(), &git_wrapper) + .expect("unchanged git sync should succeed"); assert_eq!(synced_sha, updated_sha); let invocation_log = std::fs::read_to_string(&invocation_log).expect("read sync invocations"); @@ -619,6 +636,52 @@ async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() { assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); } +#[test] +fn apple_git_without_developer_tools_is_unavailable() { + assert_eq!( + macos_git_binary_from_path( + PathBuf::from("/usr/bin/git"), + /*apple_developer_tools_available*/ false, + ), + None + ); + assert_eq!( + macos_git_binary_from_path( + PathBuf::from("/usr/bin/git"), + /*apple_developer_tools_available*/ true, + ), + Some(PathBuf::from("/usr/bin/git")) + ); + assert_eq!( + macos_git_binary_from_path( + PathBuf::from("/opt/homebrew/bin/git"), + /*apple_developer_tools_available*/ false, + ), + Some(PathBuf::from("/opt/homebrew/bin/git")) + ); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_uses_http_without_git_transport() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await; + + let synced_sha = run_sync_without_git( + tmp.path().to_path_buf(), + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .await + .expect("HTTP sync should succeed"); + + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&curated_plugins_repo_path(tmp.path())); +} + #[cfg(unix)] #[tokio::test] async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() { @@ -690,8 +753,8 @@ exit 1 ), ); - let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) - .expect_err("git sync should fail"); + let err = + sync_openai_plugins_repo_via_git(tmp.path(), &git_path).expect_err("git sync should fail"); assert!(err.contains("fatal: early EOF")); assert!(!has_plugins_clone_dirs(tmp.path())); @@ -755,7 +818,7 @@ exit 1 ), ); - let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + let err = sync_openai_plugins_repo_via_git(tmp.path(), &git_path) .expect_err("invalid staged checkout should fail"); assert!(err.contains("curated plugins archive missing marketplace manifest")); From a14b4c2d7f606d30574b888af6d05972833b815e Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 8 Jul 2026 19:46:18 +0100 Subject: [PATCH 40/53] Bound exec-server pending RPCs (#31578) ## Why An untrusted exec-server can stop reading requests, never answer them, or send guessed responses before queued requests are written. Without client-side admission, the orchestrator can retain unbounded RPC call futures and request payloads. We need a hard bound without adding a blanket timeout, because individual operations already own their timeout and cleanup semantics. ## What changed - hold one of 64 shared admission permits for the full lifetime of each regular RPC call, so an early response cannot free capacity while the call remains queued - allow `process/terminate` and `fs/close` to use one additional cleanup permit - close the transport and fail pending calls if the cleanup permit is also stuck, allowing teardown or recovery to release remote resources - leave the wire format and existing timeout behavior unchanged ## Tests - `rpc_client_call_has_no_implicit_deadline` verifies that ordinary calls remain untimed - `rpc_client_bounds_in_flight_calls_and_preserves_cleanup` covers the regular-call cap, guessed early responses, cleanup admission, and the cleanup circuit breaker --- codex-rs/exec-server/src/client.rs | 16 +- codex-rs/exec-server/src/client_recovery.rs | 2 +- codex-rs/exec-server/src/rpc.rs | 201 ++++++++++++++++++++ 3 files changed, 216 insertions(+), 3 deletions(-) diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 5590fd3b5e..2e2f4ce450 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -561,7 +561,7 @@ impl ExecServerClient { &self, process_id: &ProcessId, ) -> Result { - self.call( + self.call_for_cleanup( EXEC_TERMINATE_METHOD, &TerminateParams { process_id: process_id.clone(), @@ -592,7 +592,7 @@ impl ExecServerClient { &self, params: FsCloseParams, ) -> Result { - self.call(FS_CLOSE_METHOD, ¶ms).await + self.call_for_cleanup(FS_CLOSE_METHOD, ¶ms).await } pub async fn fs_write_file( @@ -788,6 +788,15 @@ impl ExecServerClient { { map_rpc_call_result(rpc_client.call(method, params).await) } + + async fn call_for_cleanup(&self, method: &str, params: &P) -> Result + where + P: serde::Serialize, + T: serde::de::DeserializeOwned, + { + let rpc_client = self.inner.rpc_client().await?; + map_rpc_call_result(rpc_client.call_for_cleanup(method, params).await) + } } fn map_rpc_call_result(result: Result) -> Result { @@ -830,6 +839,9 @@ impl From for ExecServerError { RpcCallError::TimedOut { method, timeout } => Self::Protocol(format!( "timed out waiting for exec-server `{method}` response after {timeout:?}" )), + RpcCallError::PendingRequestLimitExceeded { limit } => Self::Protocol(format!( + "exec-server has reached its limit of {limit} pending requests" + )), } } } diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index 252948cfc1..54ccf05046 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -445,7 +445,7 @@ impl Inner { Ok(false) => {} Err(error) => { let terminated: Result = rpc_client - .call( + .call_for_cleanup( EXEC_TERMINATE_METHOD, &TerminateParams { process_id: process_id.clone(), diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index e762fe8b66..9671f25d39 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -18,6 +18,8 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; @@ -29,6 +31,8 @@ use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; pub(crate) const SESSION_ALREADY_ATTACHED_ERROR_CODE: i64 = -32010; +const MAX_IN_FLIGHT_REGULAR_CALLS: usize = 1024; +const RESERVED_CLEANUP_CALLS: usize = 1; #[derive(Debug)] pub(crate) enum RpcCallError { @@ -40,6 +44,8 @@ pub(crate) enum RpcCallError { Server(JSONRPCErrorError), /// The executor did not return a response before the caller's deadline. TimedOut { method: String, timeout: Duration }, + /// The client already has the maximum number of regular RPC calls in flight. + PendingRequestLimitExceeded { limit: usize }, } type PendingRequest = oneshot::Sender>; @@ -240,6 +246,8 @@ pub(crate) struct RpcClient { // can be delivered for their request id. disconnected_rx: watch::Receiver, closed: Arc, + shared_call_slots: Semaphore, + cleanup_call_slots: Semaphore, next_request_id: AtomicI64, transport_tasks: Vec>, transport: JsonRpcTransport, @@ -302,6 +310,8 @@ impl RpcClient { pending, disconnected_rx, closed, + shared_call_slots: Semaphore::new(MAX_IN_FLIGHT_REGULAR_CALLS), + cleanup_call_slots: Semaphore::new(RESERVED_CLEANUP_CALLS), next_request_id: AtomicI64::new(1), transport_tasks, transport, @@ -342,6 +352,17 @@ impl RpcClient { drain_pending(&self.pending).await; } + // Callers keep this permit until `call_inner` returns, so an executor + // cannot free admission early by guessing a request id and replying before + // the request leaves the outbound queue. + fn acquire_regular_call_slot(&self) -> Result, RpcCallError> { + self.shared_call_slots.try_acquire().map_err(|_| { + RpcCallError::PendingRequestLimitExceeded { + limit: MAX_IN_FLIGHT_REGULAR_CALLS, + } + }) + } + #[tracing::instrument( name = "codex.exec_server.request", level = "info", @@ -357,6 +378,7 @@ impl RpcClient { P: Serialize, T: DeserializeOwned, { + let _call_slot = self.acquire_regular_call_slot()?; self.call_inner(method, params, RpcCallTimeout::None).await } @@ -370,10 +392,43 @@ impl RpcClient { P: Serialize, T: DeserializeOwned, { + let _call_slot = self.acquire_regular_call_slot()?; self.call_inner(method, params, RpcCallTimeout::After(call_timeout)) .await } + #[tracing::instrument( + name = "codex.exec_server.request", + level = "info", + skip_all, + fields( + otel.kind = "client", + otel.name = method, + method, + ) + )] + pub(crate) async fn call_for_cleanup( + &self, + method: &str, + params: &P, + ) -> Result + where + P: Serialize, + T: DeserializeOwned, + { + let _call_slot = match self.shared_call_slots.try_acquire() { + Ok(call_slot) => call_slot, + Err(_) => match self.cleanup_call_slots.try_acquire() { + Ok(call_slot) => call_slot, + Err(_) => { + self.close_transport().await; + return Err(RpcCallError::Closed); + } + }, + }; + self.call_inner(method, params, RpcCallTimeout::None).await + } + async fn call_inner( &self, method: &str, @@ -394,6 +449,7 @@ impl RpcClient { if self.closed.load(Ordering::Acquire) || *self.disconnected_rx.borrow() { return Err(RpcCallError::Closed); } + pending.retain(|_, response_tx| !response_tx.is_closed()); pending.insert(request_id.clone(), response_tx); } @@ -610,10 +666,13 @@ async fn drain_pending(pending: &Mutex>) { #[cfg(test)] mod tests { + use std::sync::Arc; use std::time::Duration; use codex_exec_server_protocol::JSONRPCMessage; + use codex_exec_server_protocol::JSONRPCNotification; use codex_exec_server_protocol::JSONRPCResponse; + use codex_exec_server_protocol::RequestId; use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::InMemorySpanExporter; use opentelemetry_sdk::trace::SdkTracerProvider; @@ -621,13 +680,18 @@ mod tests { use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; + use tokio::task::JoinSet; use tokio::time::timeout; use tracing::Instrument; use tracing_subscriber::filter::filter_fn; use tracing_subscriber::prelude::*; + use super::MAX_IN_FLIGHT_REGULAR_CALLS; + use super::RpcCallError; use super::RpcClient; use crate::connection::JsonRpcConnection; + use crate::connection::JsonRpcConnectionEvent; + use crate::connection::JsonRpcTransport; async fn read_jsonrpc_line(lines: &mut tokio::io::Lines>) -> JSONRPCMessage where @@ -731,6 +795,39 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn rpc_client_call_has_no_implicit_deadline() { + let (client_stdin, server_reader) = tokio::io::duplex(4096); + let (mut server_writer, client_stdout) = tokio::io::duplex(4096); + let connection = + JsonRpcConnection::from_stdio(client_stdout, client_stdin, "test-rpc".to_string()); + let (client, _events_rx) = RpcClient::new(connection); + let mut lines = BufReader::new(server_reader).lines(); + + let params = serde_json::json!({}); + let call = client.call::<_, serde_json::Value>("slow", ¶ms); + tokio::pin!(call); + assert!(futures::poll!(call.as_mut()).is_pending()); + let request = match read_jsonrpc_line(&mut lines).await { + JSONRPCMessage::Request(request) => request, + other => panic!("expected JSON-RPC request, got {other:?}"), + }; + + tokio::time::advance(Duration::from_secs(61)).await; + assert!(futures::poll!(call.as_mut()).is_pending()); + + let expected = serde_json::json!({ "value": "done" }); + write_jsonrpc_line( + &mut server_writer, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: expected.clone(), + }), + ) + .await; + assert_eq!(call.await.expect("RPC response"), expected); + } + #[tokio::test] async fn rpc_client_timeout_removes_pending_request() { let (client_stdin, server_reader) = tokio::io::duplex(4096); @@ -765,6 +862,110 @@ mod tests { } } + #[tokio::test] + async fn rpc_client_bounds_in_flight_calls_and_preserves_cleanup() { + let (outgoing_tx, outgoing_rx) = tokio::sync::mpsc::channel(/*buffer*/ 1); + outgoing_tx + .send(JSONRPCMessage::Notification(JSONRPCNotification { + method: "blocker".to_string(), + params: None, + })) + .await + .expect("outbound queue should accept the blocker"); + let (incoming_tx, incoming_rx) = tokio::sync::mpsc::channel(MAX_IN_FLIGHT_REGULAR_CALLS); + let (_disconnected_tx, disconnected_rx) = tokio::sync::watch::channel(/*init*/ false); + let connection = JsonRpcConnection { + outgoing_tx, + incoming_rx, + disconnected_rx, + task_handles: Vec::new(), + transport: JsonRpcTransport::Plain, + }; + let (client, _events_rx) = RpcClient::new(connection); + let client = Arc::new(client); + let mut calls = JoinSet::new(); + + for index in 0..MAX_IN_FLIGHT_REGULAR_CALLS { + let client = Arc::clone(&client); + calls.spawn(async move { + client + .call::<_, serde_json::Value>("pending", &serde_json::json!({ "index": index })) + .await + }); + } + timeout(Duration::from_secs(1), async { + while client.pending_request_count().await < MAX_IN_FLIGHT_REGULAR_CALLS { + tokio::task::yield_now().await; + } + }) + .await + .expect("pending requests should reach the regular limit"); + + for request_id in 1..=MAX_IN_FLIGHT_REGULAR_CALLS { + incoming_tx + .send(JsonRpcConnectionEvent::Message(JSONRPCMessage::Response( + JSONRPCResponse { + id: RequestId::Integer( + i64::try_from(request_id).expect("request id should fit in i64"), + ), + result: serde_json::json!({}), + }, + ))) + .await + .expect("reader should accept the spoofed response"); + } + timeout(Duration::from_secs(1), async { + while client.pending_request_count().await != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("spoofed responses should drain response routing"); + + let params = serde_json::json!({}); + let overflow = client.call::<_, serde_json::Value>("overflow", ¶ms); + tokio::pin!(overflow); + assert!(matches!( + futures::poll!(overflow.as_mut()), + std::task::Poll::Ready(Err(RpcCallError::PendingRequestLimitExceeded { limit })) + if limit == MAX_IN_FLIGHT_REGULAR_CALLS + )); + + let cleanup_client = Arc::clone(&client); + calls.spawn(async move { + let params = serde_json::json!({}); + cleanup_client + .call_for_cleanup::<_, serde_json::Value>("cleanup", ¶ms) + .await + }); + timeout(Duration::from_secs(1), async { + while client.pending_request_count().await != 1 { + tokio::task::yield_now().await; + } + }) + .await + .expect("cleanup request should use the reserved capacity"); + + let cleanup_params = serde_json::json!({}); + let cleanup_overflow = timeout( + Duration::from_secs(1), + client.call_for_cleanup::<_, serde_json::Value>("cleanup-overflow", &cleanup_params), + ) + .await + .expect("cleanup circuit breaker should not block"); + assert!(matches!(cleanup_overflow, Err(RpcCallError::Closed))); + assert!(client.is_disconnected()); + assert_eq!(client.pending_request_count().await, 0); + + drop(outgoing_rx); + while let Some(call) = calls.join_next().await { + assert!(matches!( + call.expect("pending call task should join"), + Err(RpcCallError::Closed) + )); + } + } + #[tokio::test(flavor = "current_thread")] async fn rpc_client_propagates_current_trace_context() { let span_exporter = InMemorySpanExporter::default(); From 1d14221af26bde3280e0eeab51e6ae4200594b20 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Wed, 8 Jul 2026 15:20:40 -0400 Subject: [PATCH 41/53] [codex] Sanitize imported session fallback titles (#29875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - sanitize Claude-derived fallback titles without changing explicit custom or generated source titles - recognize known leading control wrappers, including `ide_opened_file`, `ide_selection`, `local-command-stdout`, and `local-command-stderr` - skip separate control-only user records and use the next meaningful user message as the fallback title - use `Imported session` only when every user record is control-only - preserve raw imported messages, previews, and transcript provenance unchanged ## Why Sessions without an explicit source title fall back to user-message content. Claude metadata can arrive as separate leading user records, so stripping only the first record still exposed raw control markup or produced an unhelpful fallback. ## Impact Imported chats now receive readable fallback titles such as `Fix auth flow`, while the original wrapper-bearing messages remain visible in the imported transcript. ## Validation - `just test -p codex-external-agent-sessions` — 37 tests passed - `just test -p codex-app-server external_agent_config_import_creates_session_rollouts` — focused integration test passed - `just fix -p codex-external-agent-sessions` - `just fix -p codex-app-server` - `just bazel-lock-check` - `just fmt` --- .../tests/suite/v2/external_agent_config.rs | 68 +++++++--- .../external-agent-sessions/src/export.rs | 105 ++++++++++++++- codex-rs/external-agent-sessions/src/lib.rs | 1 + .../external-agent-sessions/src/records.rs | 32 +++-- codex-rs/external-agent-sessions/src/title.rs | 94 +++++++++++++ .../src/title_tests.rs | 123 ++++++++++++++++++ 6 files changed, 397 insertions(+), 26 deletions(-) create mode 100644 codex-rs/external-agent-sessions/src/title.rs create mode 100644 codex-rs/external-agent-sessions/src/title_tests.rs diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 6bef968941..e83b636618 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -615,6 +615,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); + let control_request = "src/auth.rs:1-5"; + let first_request = "Fix auth flow"; std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::write( @@ -624,7 +626,14 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, - "message": { "content": "first request" }, + "message": { "content": control_request }, + }) + .to_string(), + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": first_request }, }) .to_string(), serde_json::json!({ @@ -634,11 +643,6 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { "message": { "content": "first answer" }, }) .to_string(), - serde_json::json!({ - "type": "custom-title", - "customTitle": "source session title", - }) - .to_string(), ] .join("\n"), )?; @@ -667,6 +671,14 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .await??; let detected: ExternalAgentConfigDetectResponse = to_response(response)?; assert_eq!(detected.items.len(), 1); + assert_eq!( + detected.items[0] + .details + .as_ref() + .and_then(|details| details.sessions.first()) + .and_then(|session| session.title.as_deref()), + Some("Fix auth flow") + ); let request_id = mcp .send_raw_request( @@ -743,8 +755,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .expect("expected imported thread") .clone(); assert_eq!(imported_thread_id, thread.id.to_string()); - assert_eq!(thread.preview, "first request"); - assert_eq!(thread.name.as_deref(), Some("source session title")); + assert_eq!(thread.preview, control_request); + assert_eq!(thread.name.as_deref(), Some("Fix auth flow")); let request_id = mcp .send_thread_read_request(ThreadReadParams { @@ -758,13 +770,39 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { ) .await??; let response: ThreadReadResponse = to_response(response)?; - assert_eq!(response.thread.turns.len(), 1); - let items = &response.thread.turns[0].items; - assert_eq!(items.len(), 3); + assert_eq!(response.thread.turns.len(), 2); + let control_items = &response.thread.turns[0].items; + assert_eq!(control_items.len(), 1); + match &control_items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: control_request.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + let imported_items = &response.thread.turns[1].items; + assert_eq!(imported_items.len(), 3); + match &imported_items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: first_request.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } assert_eq!( - items.last(), + imported_items.last(), Some(&ThreadItem::AgentMessage { - id: "item-3".into(), + id: "item-4".into(), text: "".into(), phase: None, memory_citation: None, @@ -818,8 +856,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { ) .await??; let response: ThreadReadResponse = to_response(response)?; - assert_eq!(response.thread.turns.len(), 2); - match &response.thread.turns[1].items[1] { + assert_eq!(response.thread.turns.len(), 3); + match &response.thread.turns[2].items[1] { ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "follow-up answer"), other => panic!("expected agent message item, got {other:?}"), } diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 6391d27bd3..992770f855 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -3,6 +3,9 @@ use crate::ImportedExternalAgentSession; use crate::MessageRole; use crate::records::read_session_import; use crate::summarize_for_label; +use crate::title::IMPORTED_SESSION_FALLBACK_TITLE; +use crate::title::SessionTitleCandidates; +use crate::title::fallback_title_from_user_message; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; @@ -36,11 +39,22 @@ pub(crate) fn load_session_for_import_with_content_sha256( return Ok(None); }; let messages = parsed.messages; - let first_user_message = messages + let first_user_message_text = messages .iter() .find(|message| message.role == MessageRole::User) - .map(|message| summarize_for_label(&message.text)); - let title = parsed.source_title.or_else(|| first_user_message.clone()); + .map(|message| message.text.as_str()); + let first_user_message = first_user_message_text.map(summarize_for_label); + let fallback_title = messages + .iter() + .filter(|message| message.role == MessageRole::User) + .find_map(|message| fallback_title_from_user_message(&message.text)) + .or_else(|| first_user_message_text.map(|_| IMPORTED_SESSION_FALLBACK_TITLE.to_string())); + let title = SessionTitleCandidates { + custom_title: parsed.custom_title, + ai_title: parsed.ai_title, + fallback_title, + } + .select(); let rollout_items = rollout_items_from_messages(messages); if rollout_items.is_empty() { return Ok(None); @@ -373,6 +387,91 @@ mod tests { assert_eq!(imported.title.as_deref(), Some("named by source app")); } + #[test] + fn sanitizes_only_the_imported_session_fallback_title() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let message = "\ncontrol context\n\nFix auth flow"; + std::fs::write(&path, jsonl(&[record("user", message, &project_root)])).expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + let imported_user_message = imported.rollout_items.iter().find_map(|item| match item { + RolloutItem::EventMsg(EventMsg::UserMessage(event)) => Some(event.message.as_str()), + _ => None, + }); + + assert_eq!(imported.title.as_deref(), Some("Fix auth flow")); + assert_eq!( + imported.first_user_message.as_deref(), + Some("") + ); + assert_eq!(imported_user_message, Some(message)); + } + + #[test] + fn skips_control_only_user_messages_when_choosing_fallback_title() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let control_message = "src/auth.rs:1-5"; + std::fs::write( + &path, + jsonl(&[ + record("user", control_message, &project_root), + record("user", "Fix auth flow", &project_root), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + + assert_eq!(imported.title.as_deref(), Some("Fix auth flow")); + assert_eq!( + imported.first_user_message.as_deref(), + Some(control_message) + ); + } + + #[test] + fn uses_safe_fallback_after_all_user_messages_are_control_only() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + std::fs::write( + &path, + jsonl(&[ + record( + "user", + "src/auth.rs:1-5", + &project_root, + ), + record( + "user", + "tests failed", + &project_root, + ), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + + assert_eq!( + imported.title.as_deref(), + Some(IMPORTED_SESSION_FALLBACK_TITLE) + ); + } + #[test] fn emits_token_usage_for_imported_history() { let root = TempDir::new().expect("tempdir"); diff --git a/codex-rs/external-agent-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index 0b7a4eb2ba..b13322dffb 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -4,6 +4,7 @@ mod detect; mod export; mod ledger; mod records; +mod title; use codex_protocol::protocol::RolloutItem; use std::io; diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 00307fa1d5..2901714a19 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -1,7 +1,9 @@ use crate::ConversationMessage; use crate::ExternalAgentSessionMigration; use crate::MessageRole; -use crate::summarize_for_label; +use crate::title::IMPORTED_SESSION_FALLBACK_TITLE; +use crate::title::SessionTitleCandidates; +use crate::title::fallback_title_from_user_message; use crate::truncate; use serde_json::Value as JsonValue; use sha2::Digest; @@ -25,7 +27,8 @@ pub struct SessionSummary { pub(super) struct ParsedSessionImport { pub cwd: Option, - pub source_title: Option, + pub custom_title: Option, + pub ai_title: Option, pub messages: Vec, pub content_sha256: String, } @@ -36,7 +39,8 @@ pub fn summarize_session(path: &Path) -> io::Result> { let mut cwd = None; let mut custom_title = None; let mut ai_title = None; - let mut title = None; + let mut fallback_title = None; + let mut saw_user_message = false; let mut latest_timestamp = None; let mut saw_message = false; @@ -65,8 +69,11 @@ pub fn summarize_session(path: &Path) -> io::Result> { continue; }; saw_message = true; - if title.is_none() && message.role == MessageRole::User { - title = Some(summarize_for_label(&message.text)); + if message.role == MessageRole::User { + saw_user_message = true; + if fallback_title.is_none() { + fallback_title = fallback_title_from_user_message(&message.text); + } } if let Some(timestamp) = message.timestamp { latest_timestamp = @@ -88,7 +95,14 @@ pub fn summarize_session(path: &Path) -> io::Result> { migration: ExternalAgentSessionMigration { path: path.to_path_buf(), cwd, - title: custom_title.or(ai_title).or(title), + title: SessionTitleCandidates { + custom_title, + ai_title, + fallback_title: fallback_title.or_else(|| { + saw_user_message.then(|| IMPORTED_SESSION_FALLBACK_TITLE.to_string()) + }), + } + .select(), }, })) } @@ -133,7 +147,8 @@ pub(super) fn read_session_import(path: &Path) -> io::Result", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), +]; + +pub(super) struct SessionTitleCandidates { + pub custom_title: Option, + pub ai_title: Option, + pub fallback_title: Option, +} + +impl SessionTitleCandidates { + pub fn select(self) -> Option { + self.custom_title.or(self.ai_title).or(self.fallback_title) + } +} + +pub(super) fn fallback_title_from_user_message(message: &str) -> Option { + let message = strip_leading_control_wrappers(message); + message + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| truncate(line, SESSION_TITLE_MAX_LEN)) +} + +fn strip_leading_control_wrappers(message: &str) -> &str { + let mut remainder = message.trim_start(); + while let Some(wrapper_end) = leading_control_wrapper_end(remainder) { + remainder = remainder[wrapper_end..].trim_start(); + } + remainder +} + +fn leading_control_wrapper_end(text: &str) -> Option { + let (outer_tag, opening_len) = recognized_opening_tag(text)?; + let mut open_tags = vec![outer_tag]; + let mut cursor = opening_len; + + while !open_tags.is_empty() { + cursor += text.get(cursor..)?.find('<')?; + let candidate = text.get(cursor..)?; + if let Some((tag, token_len)) = recognized_opening_tag(candidate) { + open_tags.push(tag); + cursor += token_len; + continue; + } + if let Some((tag, token_len)) = recognized_closing_tag(candidate) { + if open_tags.last().copied() != Some(tag) { + return None; + } + open_tags.pop(); + cursor += token_len; + continue; + } + cursor += 1; + } + + Some(cursor) +} + +fn recognized_opening_tag(text: &str) -> Option<(usize, usize)> { + RECOGNIZED_CONTROL_WRAPPERS + .iter() + .enumerate() + .find_map(|(index, (opening, _closing))| { + text.starts_with(opening).then_some((index, opening.len())) + }) +} + +fn recognized_closing_tag(text: &str) -> Option<(usize, usize)> { + RECOGNIZED_CONTROL_WRAPPERS + .iter() + .enumerate() + .find_map(|(index, (_opening, closing))| { + text.starts_with(closing).then_some((index, closing.len())) + }) +} + +#[cfg(test)] +#[path = "title_tests.rs"] +mod tests; diff --git a/codex-rs/external-agent-sessions/src/title_tests.rs b/codex-rs/external-agent-sessions/src/title_tests.rs new file mode 100644 index 0000000000..fc068c0e39 --- /dev/null +++ b/codex-rs/external-agent-sessions/src/title_tests.rs @@ -0,0 +1,123 @@ +use super::*; + +#[test] +fn preserves_valid_custom_title_unchanged() { + let custom_title = "Keep this custom title"; + + assert_eq!( + SessionTitleCandidates { + custom_title: Some(custom_title.to_string()), + ai_title: Some("AI title".to_string()), + fallback_title: Some("fallback title".to_string()), + } + .select(), + Some(custom_title.to_string()) + ); +} + +#[test] +fn preserves_valid_ai_title_unchanged_without_custom_title() { + let ai_title = "Keep this AI title"; + + assert_eq!( + SessionTitleCandidates { + custom_title: None, + ai_title: Some(ai_title.to_string()), + fallback_title: Some("fallback title".to_string()), + } + .select(), + Some(ai_title.to_string()) + ); +} + +#[test] +fn strips_nested_repeated_and_multiline_leading_control_wrappers() { + let message = "\ + \n\ + outer context\n\ + \n\ + nested context\n\ + \n\ + \n\ + \n\ + src/auth.rs\n\ + \n\ + \n\ + Fix auth flow\n\ + Additional details"; + + assert_eq!( + fallback_title_from_user_message(message), + Some("Fix auth flow".to_string()) + ); +} + +#[test] +fn strips_observed_external_agent_control_wrapper_families() { + let cases = [ + "\n\ + abc123\n\ + completed\n\ + \n\ + Fix auth flow", + "review\n\ + /review\n\ + src/auth.rs\n\ + Fix auth flow", + "Command output follows\n\ + tests passed\n\ + Fix auth flow", + "tests failed\n\ + Fix auth flow", + "src/auth.rs:1-5\n\ + Fix auth flow", + ]; + + for message in cases { + assert_eq!( + fallback_title_from_user_message(message), + Some("Fix auth flow".to_string()) + ); + } +} + +#[test] +fn returns_no_candidate_for_empty_or_control_only_messages() { + assert_eq!(fallback_title_from_user_message(""), None); + assert_eq!( + fallback_title_from_user_message( + "review\n\ + context" + ), + None + ); +} + +#[test] +fn uses_first_meaningful_line_from_ordinary_messages() { + assert_eq!( + fallback_title_from_user_message("\n \n Fix auth flow \nAdditional details"), + Some("Fix auth flow".to_string()) + ); +} + +#[test] +fn preserves_unknown_and_user_authored_angle_bracket_text() { + assert_eq!( + fallback_title_from_user_message("Keep this text Fix auth flow"), + Some("Keep this text Fix auth flow".to_string()) + ); + assert_eq!( + fallback_title_from_user_message("Explain tags"), + Some("Explain tags".to_string()) + ); +} + +#[test] +fn bounds_fallback_titles_to_120_characters() { + let message = "x".repeat(121); + let title = fallback_title_from_user_message(&message).expect("title"); + + assert_eq!(title.chars().count(), SESSION_TITLE_MAX_LEN); + assert_eq!(title, format!("{}...", "x".repeat(117))); +} From 927004c06dc55565af17f0bc8eeb5e35fb990351 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Wed, 8 Jul 2026 12:29:21 -0700 Subject: [PATCH 42/53] tui: warn on Ultra with high multi-agent concurrency (#31621) ## Why Ultra reasoning may proactively use multiple agents. When `features.multi_agent_v2.max_concurrent_threads_per_session` is configured at 8 or higher, explicitly selecting Ultra can allow up to `N - 1` subagents to work concurrently and increase usage quickly. Showing the configured limits at selection time makes that tradeoff visible and points users to the setting that controls it. ## What changed - Show a warning history cell after the user explicitly selects Ultra reasoning with a concurrent-thread limit of at least 8. - Include the configured concurrent-thread count and maximum subagent count in the warning. - Apply the warning consistently across model and reasoning pickers, Plan-mode scope selection, and reasoning shortcuts. - Keep the trigger limited to the selected reasoning effort and configured thread limit, independent of how multi-agent v2 is activated. - Add focused threshold coverage and an `insta` snapshot for the rendered warning. ## User impact Users with high multi-agent concurrency receive a concrete warning immediately after selecting Ultra. Other reasoning efforts, limits below 8, and startup behavior are unchanged. https://github.com/user-attachments/assets/5795bcb9-432e-42bc-bde3-ee363e9aeb69 ## Test plan - `just test -p codex-tui ultra_reasoning_selection` - Built the full debug CLI with `cargo build -p codex-cli`. - Manually verified the debug TUI with a 10-thread limit: switching from Max to Ultra displayed the warning with 10 concurrent threads and up to 9 subagents. --- codex-rs/tui/src/chatwidget/model_popups.rs | 66 ++++++++++++++++++- .../tui/src/chatwidget/reasoning_shortcuts.rs | 6 ++ ..._high_multi_agent_concurrency_warning.snap | 8 +++ .../chatwidget/tests/popups_and_settings.rs | 62 +++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__ultra_reasoning_selection_high_multi_agent_concurrency_warning.snap diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 1194fc15c6..b7d218c5bc 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -5,6 +5,8 @@ use super::*; +const ULTRA_REASONING_CONCURRENCY_WARNING_THRESHOLD: usize = 8; + impl ChatWidget { /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. @@ -102,7 +104,7 @@ impl ChatWidget { model.as_str(), Some(preset.default_reasoning_effort.clone()), ); - let actions = Self::model_selection_actions( + let actions = self.model_selection_actions( model.clone(), Some(preset.default_reasoning_effort.clone()), should_prompt_plan_mode_scope, @@ -214,10 +216,14 @@ impl ChatWidget { } fn model_selection_actions( + &self, model_for_action: String, effort_for_action: Option, should_prompt_plan_mode_scope: bool, ) -> Vec { + let warning = effort_for_action + .as_ref() + .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { @@ -233,6 +239,11 @@ impl ChatWidget { model: model_for_action.clone(), effort: effort_for_action.clone(), }); + if let Some(warning) = warning.clone() { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(warning), + ))); + } })] } @@ -299,14 +310,23 @@ impl ChatWidget { "Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}." ); let subtitle = format!("Choose where to apply {reasoning_phrase}."); + let warning = effort + .as_ref() + .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); let effort = effort.clone(); + let warning = warning.clone(); move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); + if let Some(warning) = warning.clone() { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(warning), + ))); + } } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { @@ -318,6 +338,11 @@ impl ChatWidget { model: model.clone(), effort: effort.clone(), }); + if let Some(warning) = warning.clone() { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(warning), + ))); + } })]; self.bottom_pane.show_selection_view(SelectionViewParams { @@ -427,6 +452,7 @@ impl ChatWidget { let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.clone(); + let warning = self.ultra_reasoning_concurrency_warning(&effort); let mut effort_label = Self::reasoning_effort_label(&effort); if Some(choice) == default_choice.as_ref() { effort_label.push_str(" (default)"); @@ -469,6 +495,11 @@ impl ChatWidget { model: model_for_action.clone(), effort: choice_effort.clone(), }); + if let Some(warning) = warning.clone() { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(warning), + ))); + } } })]; @@ -518,14 +549,47 @@ impl ChatWidget { } } + pub(super) fn ultra_reasoning_concurrency_warning( + &self, + effort: &ReasoningEffortConfig, + ) -> Option { + if effort != &ReasoningEffortConfig::Ultra { + return None; + } + + let max_threads = self + .config + .multi_agent_v2 + .max_concurrent_threads_per_session; + if max_threads < ULTRA_REASONING_CONCURRENCY_WARNING_THRESHOLD { + return None; + } + + let max_subagents = max_threads.saturating_sub(1); + Some(format!( + "Ultra reasoning may proactively use multiple agents. This session is configured for \ + {max_threads} concurrent threads with up to {max_subagents} subagents which can \ + increase usage quickly. Consider setting \ + features.multi_agent_v2.max_concurrent_threads_per_session below 8." + )) + } + pub(super) fn apply_model_and_effort_without_persist( &self, model: String, effort: Option, ) { + let warning = effort + .as_ref() + .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); self.app_event_tx.send(AppEvent::UpdateModel(model)); self.app_event_tx .send(AppEvent::UpdateReasoningEffort(effort)); + if let Some(warning) = warning { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(warning), + ))); + } } fn apply_model_and_effort(&self, model: String, effort: Option) { diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index 0873bda6e0..b611281d0b 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -110,8 +110,14 @@ impl ChatWidget { }; if self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan { + let warning = self.ultra_reasoning_concurrency_warning(&next_effort); self.app_event_tx .send(AppEvent::UpdatePlanModeReasoningEffort(Some(next_effort))); + if let Some(warning) = warning { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_warning_event(warning), + ))); + } } else { self.apply_model_and_effort_without_persist(current_model, Some(next_effort)); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__ultra_reasoning_selection_high_multi_agent_concurrency_warning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__ultra_reasoning_selection_high_multi_agent_concurrency_warning.snap new file mode 100644 index 0000000000..56ebfb498b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__ultra_reasoning_selection_high_multi_agent_concurrency_warning.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: "&warnings[0]" +--- +⚠ Ultra reasoning may proactively use multiple agents. This session is + configured for 8 concurrent threads with up to 7 subagents which can increase + usage quickly. Consider setting + features.multi_agent_v2.max_concurrent_threads_per_session below 8. diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 07cfc7dbbb..1b460f81db 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3236,6 +3236,68 @@ async fn model_reasoning_selection_popup_applies_custom_effort() { ); } +async fn select_ultra_with_multi_agent_thread_limit(max_threads: usize) -> (bool, Vec) { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.config + .multi_agent_v2 + .max_concurrent_threads_per_session = max_threads; + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.default_reasoning_effort = ReasoningEffortConfig::High; + preset.supported_reasoning_efforts = vec![ + ReasoningEffortPreset { + effort: ReasoningEffortConfig::High, + description: "High reasoning".to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }, + ]; + chat.open_reasoning_popup(preset); + while rx.try_recv().is_ok() {} + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let mut selected_ultra = false; + let mut warnings = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Ultra)) => { + selected_ultra = true; + } + AppEvent::InsertHistoryCell(cell) => { + warnings.push(lines_to_single_string(&cell.display_lines(/*width*/ 80))); + } + _ => {} + } + } + + (selected_ultra, warnings) +} + +#[tokio::test] +async fn ultra_reasoning_selection_warns_for_high_multi_agent_concurrency() { + let (selected_ultra, warnings) = + select_ultra_with_multi_agent_thread_limit(/*max_threads*/ 8).await; + + assert!(selected_ultra); + assert_eq!(warnings.len(), 1); + assert_chatwidget_snapshot!( + "ultra_reasoning_selection_high_multi_agent_concurrency_warning", + &warnings[0] + ); +} + +#[tokio::test] +async fn ultra_reasoning_selection_skips_warning_below_threshold() { + let below_threshold = select_ultra_with_multi_agent_thread_limit(/*max_threads*/ 7).await; + + assert_eq!(below_threshold, (true, Vec::new())); +} + #[tokio::test] async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; From 2780bd588f8170abd41455d5509409ae0f76ffe9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 8 Jul 2026 12:57:27 -0700 Subject: [PATCH 43/53] websocket-client: add proxy-aware connector (#31622) ## Why The route-aware WebSocket connection setup in #31441 is transport infrastructure rather than Responses API protocol logic. Landing it first in a dedicated crate keeps `codex-api` focused on request and response behavior and makes the transport reusable by future WebSocket clients. WebSockets must also apply the same effective outbound proxy and custom-CA policy as HTTP without disabling the lower-latency WebSocket path. Requiring an `HttpClientFactory` when constructing the connector makes proxy-policy resolution part of the API instead of an optional call-site convention. This PR is an independent prerequisite based directly on `main`. After it merges, #31441 can rebase onto it and replace its in-crate connector with this API. ## What changed - Add a new `codex-websocket-client` workspace crate with a `WebSocketConnector` constructed from the effective `HttpClientFactory`. - Resolve every destination through that factory before connecting, then support direct connections, transport-default routing, HTTP proxies, and TLS-encrypted HTTPS proxies. - Preserve custom-CA trust for proxy and target TLS handshakes and preserve Happy Eyeballs fallback for explicit direct and proxy routes. - Expose an established `WebSocketConnection` as a uniform `Stream` and `Sink`, hiding route-specific transport types from protocol clients. - Add focused integration-style coverage for the public connector and message stream, real WSS over direct and CONNECT routes, implicit and explicit HTTPS proxy ports, and stalled-address-family fallback. ## Review guide 1. `codex-rs/websocket-client/src/lib.rs` defines the small public API and the factory-required policy invariant. 2. `codex-rs/websocket-client/src/dialer.rs` contains DNS, TCP, proxy tunneling, TLS, and WebSocket handshake setup. 3. `codex-rs/websocket-client/src/dialer_tests.rs` verifies the public stream, direct and proxied WSS paths, HTTPS port preservation, and Happy Eyeballs timing. 4. There is intentionally no consumer migration here; #31441 will become the first consumer after this prerequisite merges. ## Test plan - `cargo check -p codex-websocket-client --tests` - `just test -p codex-websocket-client` - `cargo shear` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 16 + codex-rs/Cargo.toml | 2 + codex-rs/websocket-client/BUILD.bazel | 6 + codex-rs/websocket-client/Cargo.toml | 26 ++ codex-rs/websocket-client/src/dialer.rs | 256 +++++++++++++++ codex-rs/websocket-client/src/dialer_tests.rs | 297 ++++++++++++++++++ codex-rs/websocket-client/src/lib.rs | 133 ++++++++ 7 files changed, 736 insertions(+) create mode 100644 codex-rs/websocket-client/BUILD.bazel create mode 100644 codex-rs/websocket-client/Cargo.toml create mode 100644 codex-rs/websocket-client/src/dialer.rs create mode 100644 codex-rs/websocket-client/src/dialer_tests.rs create mode 100644 codex-rs/websocket-client/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5aba59500c..9856ef9cd3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4485,6 +4485,22 @@ dependencies = [ "url", ] +[[package]] +name = "codex-websocket-client" +version = "0.0.0" +dependencies = [ + "codex-http-client", + "codex-utils-rustls-provider", + "futures", + "pretty_assertions", + "rcgen", + "rustls", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "url", +] + [[package]] name = "codex-windows-sandbox" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0c1a4f9711..0085b663ef 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -90,6 +90,7 @@ members = [ "tui", "tools", "v8-poc", + "websocket-client", "utils/absolute-path", "utils/path-uri", "utils/cargo-bin", @@ -427,6 +428,7 @@ thiserror = "2.0.17" time = "0.3.47" tiny_http = "0.12" tokio = "1" +tokio-rustls = "0.26.4" tokio-stream = "0.1.18" tokio-test = "0.4" tokio-tungstenite = { version = "0.28.0", features = [ diff --git a/codex-rs/websocket-client/BUILD.bazel b/codex-rs/websocket-client/BUILD.bazel new file mode 100644 index 0000000000..0c82049c28 --- /dev/null +++ b/codex-rs/websocket-client/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "websocket-client", + crate_name = "codex_websocket_client", +) diff --git a/codex-rs/websocket-client/Cargo.toml b/codex-rs/websocket-client/Cargo.toml new file mode 100644 index 0000000000..19baf9842b --- /dev/null +++ b/codex-rs/websocket-client/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "codex-websocket-client" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +codex-http-client = { workspace = true } +futures = { workspace = true } +rustls = { workspace = true } +tokio = { workspace = true, features = ["net", "rt", "time"] } +tokio-rustls = { workspace = true } +tokio-tungstenite = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +codex-utils-rustls-provider = { workspace = true } +pretty_assertions = { workspace = true } +rcgen = { workspace = true } +tokio = { workspace = true, features = ["macros", "test-util"] } + +[lints] +workspace = true + +[lib] +doctest = false diff --git a/codex-rs/websocket-client/src/dialer.rs b/codex-rs/websocket-client/src/dialer.rs new file mode 100644 index 0000000000..29d4f42884 --- /dev/null +++ b/codex-rs/websocket-client/src/dialer.rs @@ -0,0 +1,256 @@ +use std::collections::VecDeque; +use std::future::Future; +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use codex_http_client::OutboundProxyRoute; +use futures::StreamExt; +use futures::stream::FuturesUnordered; +use rustls::ClientConfig; +use rustls::pki_types::ServerName; +use tokio::net::TcpStream; +use tokio::time::Instant; +use tokio::time::sleep_until; +use tokio_rustls::TlsConnector; +use tokio_tungstenite::Connector; +use tokio_tungstenite::client_async_tls_with_config; +use tokio_tungstenite::connect_async_tls_with_config; +use tokio_tungstenite::proxy::connect_via_proxy; +use tokio_tungstenite::tungstenite::Error as WebSocketError; +use tokio_tungstenite::tungstenite::error::TlsError; +use tokio_tungstenite::tungstenite::error::UrlError; +use tokio_tungstenite::tungstenite::handshake::client::Request; +use tokio_tungstenite::tungstenite::handshake::client::Response; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use tokio_tungstenite::tungstenite::proxy::ProxyConfig; + +use crate::AsyncIo; +use crate::ConnectionInner; + +const HAPPY_EYEBALLS_DELAY: Duration = Duration::from_millis(250); + +pub(crate) async fn connect( + request: Request, + config: WebSocketConfig, + tls_config: Arc, + proxy_route: OutboundProxyRoute, +) -> Result<(ConnectionInner, Response), WebSocketError> { + let stream: Box = match proxy_route { + OutboundProxyRoute::TransportDefault => { + // The workspace enables tokio-tungstenite's `proxy` feature, so its default dialer + // resolves HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY before opening the socket. + let (stream, response) = connect_async_tls_with_config( + request, + Some(config), + false, // Preserve Tungstenite's recommended Nagle default. + Some(Connector::Rustls(tls_config)), + ) + .await?; + return Ok((ConnectionInner::TransportDefault(stream), response)); + } + OutboundProxyRoute::Direct => { + let host = websocket_host(&request)?; + let port = websocket_port(&request)?; + Box::new( + connect_tcp(host_port(host, port)) + .await + .map_err(WebSocketError::Io)?, + ) + } + OutboundProxyRoute::Proxy { url } => { + let proxy = ProxyEndpoint::parse(&url)?; + let host = websocket_host(&request)?; + let port = websocket_port(&request)?; + let stream = connect_tcp(proxy.config.authority()) + .await + .map_err(WebSocketError::Io)?; + let stream: Box = if proxy.tls { + let server_name = ServerName::try_from(proxy.config.host.clone()) + .map_err(|_| WebSocketError::Tls(TlsError::InvalidDnsName))?; + let stream = TlsConnector::from(Arc::clone(&tls_config)) + .connect(server_name, stream) + .await + .map_err(WebSocketError::Io)?; + Box::new(stream) + } else { + Box::new(stream) + }; + connect_via_proxy(stream, &proxy.config, host, port).await? + } + }; + + let (stream, response) = client_async_tls_with_config( + request, + stream, + Some(config), + Some(Connector::Rustls(tls_config)), + ) + .await?; + Ok((ConnectionInner::Routed(stream), response)) +} + +#[derive(Debug, PartialEq, Eq)] +struct ProxyEndpoint { + config: ProxyConfig, + tls: bool, +} + +impl ProxyEndpoint { + fn parse(url: &str) -> Result { + let mut parsed_url = url::Url::parse(url).map_err(|_| invalid_proxy_config())?; + let tls = parsed_url.scheme() == "https"; + if tls { + // Capture the HTTPS default before changing schemes: `Url` normalizes default ports, + // so setting 443 before rewriting to HTTP would discard it and later imply port 80. + let port = parsed_url + .port_or_known_default() + .ok_or_else(invalid_proxy_config)?; + parsed_url + .set_scheme("http") + .map_err(|_| invalid_proxy_config())?; + parsed_url + .set_port(Some(port)) + .map_err(|_| invalid_proxy_config())?; + } + let config = ProxyConfig::parse(parsed_url.as_str()).map_err(|error| match error { + WebSocketError::Url(UrlError::UnsupportedProxyScheme) => error, + _ => invalid_proxy_config(), + })?; + Ok(Self { config, tls }) + } +} + +fn invalid_proxy_config() -> WebSocketError { + WebSocketError::Url(UrlError::InvalidProxyConfig("".to_string())) +} + +fn websocket_host(request: &Request) -> Result<&str, WebSocketError> { + request + .uri() + .host() + .ok_or(WebSocketError::Url(UrlError::NoHostName)) +} + +fn websocket_port(request: &Request) -> Result { + request + .uri() + .port_u16() + .or_else(|| match request.uri().scheme_str() { + Some("ws") => Some(80), + Some("wss") => Some(443), + _ => None, + }) + .ok_or(WebSocketError::Url(UrlError::UnsupportedUrlScheme)) +} + +fn host_port(host: &str, port: u16) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +async fn connect_tcp(address: String) -> io::Result { + let addresses = tokio::net::lookup_host(address).await?.collect::>(); + connect_happy_eyeballs(addresses, TcpStream::connect).await +} + +async fn connect_happy_eyeballs( + addresses: Vec, + mut connect: F, +) -> io::Result +where + F: FnMut(SocketAddr) -> Fut, + Fut: Future>, +{ + let mut addresses = addresses.into_iter(); + let Some(first_address) = addresses.next() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "could not resolve to any address", + )); + }; + + let first_is_ipv4 = first_address.is_ipv4(); + let mut preferred = VecDeque::new(); + let mut alternate = VecDeque::new(); + for address in addresses { + if address.is_ipv4() == first_is_ipv4 { + preferred.push_back(address); + } else { + alternate.push_back(address); + } + } + + let mut addresses = VecDeque::new(); + while !preferred.is_empty() || !alternate.is_empty() { + if let Some(address) = alternate.pop_front() { + addresses.push_back(address); + } + if let Some(address) = preferred.pop_front() { + addresses.push_back(address); + } + } + + let mut attempts = FuturesUnordered::new(); + attempts.push(connect(first_address)); + let mut next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY; + let mut last_error = None; + + loop { + if addresses.is_empty() { + match attempts.next().await { + Some(Ok(stream)) => return Ok(stream), + Some(Err(error)) => { + if attempts.is_empty() { + return Err(error); + } + last_error = Some(error); + } + None => { + return Err(last_error.unwrap_or_else(|| { + io::Error::other("connection attempts ended without an error") + })); + } + } + continue; + } + + tokio::select! { + result = attempts.next() => { + match result { + Some(Ok(stream)) => return Ok(stream), + Some(Err(error)) => { + last_error = Some(error); + let address = take_next_address(&mut addresses)?; + attempts.push(connect(address)); + next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY; + } + None => { + let address = take_next_address(&mut addresses)?; + attempts.push(connect(address)); + next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY; + } + } + } + _ = sleep_until(next_attempt_at) => { + let address = take_next_address(&mut addresses)?; + attempts.push(connect(address)); + next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY; + } + } + } +} + +fn take_next_address(addresses: &mut VecDeque) -> io::Result { + addresses + .pop_front() + .ok_or_else(|| io::Error::other("connection address queue unexpectedly empty")) +} + +#[cfg(test)] +#[path = "dialer_tests.rs"] +mod tests; diff --git a/codex-rs/websocket-client/src/dialer_tests.rs b/codex-rs/websocket-client/src/dialer_tests.rs new file mode 100644 index 0000000000..4d7ae7731d --- /dev/null +++ b/codex-rs/websocket-client/src/dialer_tests.rs @@ -0,0 +1,297 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use codex_http_client::OutboundProxyRoute; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use rcgen::CertifiedKey; +use rcgen::generate_simple_self_signed; +use rustls::ClientConfig; +use rustls::RootCertStore; +use rustls::ServerConfig; +use rustls::pki_types::PrivateKeyDer; +use rustls::pki_types::PrivatePkcs8KeyDer; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use tokio_tungstenite::tungstenite::proxy::ProxyScheme; + +use super::*; +use crate::AsyncIo; +use crate::WebSocketConnection; +use crate::WebSocketConnector; + +#[tokio::test] +async fn public_connector_uses_factory_and_exposes_stream_and_sink() { + let (target_addr, target_task) = start_plain_echo_websocket_server().await; + let request = format!("ws://localhost:{}/v1/responses", target_addr.port()) + .into_client_request() + .expect("websocket request should build"); + let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault); + let connector = WebSocketConnector::new(&factory).expect("connector should build"); + + let (mut websocket, _) = connector + .connect(request, WebSocketConfig::default()) + .await + .expect("websocket handshake should succeed"); + let expected = Message::Text("hello".into()); + websocket + .send(expected.clone()) + .await + .expect("websocket should send"); + let actual = websocket + .next() + .await + .expect("websocket should receive a message") + .expect("websocket message should be valid"); + assert_eq!(actual, expected); + + target_task.await.expect("target task should finish"); +} + +#[tokio::test] +async fn direct_route_connects_secure_websocket() { + let (tls_config, acceptor) = test_tls_configs(); + let (target_addr, target_task) = start_tls_websocket_server(acceptor).await; + let request = format!("wss://localhost:{}/v1/responses", target_addr.port()) + .into_client_request() + .expect("websocket request should build"); + + let (inner, _) = connect( + request, + WebSocketConfig::default(), + tls_config, + OutboundProxyRoute::Direct, + ) + .await + .expect("direct websocket handshake should succeed"); + drop(WebSocketConnection { inner }); + + target_task.await.expect("target task should finish"); +} + +#[tokio::test] +async fn http_proxy_tunnels_secure_websocket_before_handshake() { + assert_proxy_tunnels_secure_websocket(/*proxy_tls*/ false).await; +} + +#[tokio::test] +async fn https_proxy_tunnels_secure_websocket_before_handshake() { + assert_proxy_tunnels_secure_websocket(/*proxy_tls*/ true).await; +} + +#[test] +fn https_proxy_defaults_to_port_443_and_preserves_explicit_port() { + let default_port = ProxyEndpoint::parse("https://proxy.example") + .expect("HTTPS proxy without a port should parse"); + let explicit_port = ProxyEndpoint::parse("https://proxy.example:8443") + .expect("HTTPS proxy with a port should parse"); + + assert_eq!( + default_port, + ProxyEndpoint { + config: ProxyConfig { + scheme: ProxyScheme::Http, + host: "proxy.example".to_string(), + port: 443, + auth: None, + }, + tls: true, + } + ); + assert_eq!( + explicit_port, + ProxyEndpoint { + config: ProxyConfig { + scheme: ProxyScheme::Http, + host: "proxy.example".to_string(), + port: 8443, + auth: None, + }, + tls: true, + } + ); +} + +#[tokio::test(start_paused = true)] +async fn happy_eyeballs_does_not_wait_for_stalled_preferred_family() { + let stalled = "[2001:db8::1]:443" + .parse::() + .expect("stalled address should parse"); + let reachable = "127.0.0.1:443" + .parse::() + .expect("reachable address should parse"); + + let connected = tokio::time::timeout( + Duration::from_secs(1), + connect_happy_eyeballs(vec![stalled, reachable], |address| async move { + if address == stalled { + std::future::pending::<()>().await; + } + Ok(address) + }), + ) + .await + .expect("alternate family should start before timeout") + .expect("alternate family should connect"); + + assert_eq!(connected, reachable); +} + +async fn start_plain_echo_websocket_server() -> (SocketAddr, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("target listener should bind"); + let address = listener + .local_addr() + .expect("target listener should have an address"); + let task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("target should accept"); + let mut websocket = accept_async(stream) + .await + .expect("target websocket handshake should succeed"); + let message = websocket + .next() + .await + .expect("target should receive a message") + .expect("target websocket message should be valid"); + websocket + .send(message) + .await + .expect("target should echo the message"); + }); + (address, task) +} + +async fn assert_proxy_tunnels_secure_websocket(proxy_tls: bool) { + let (tls_config, acceptor) = test_tls_configs(); + let (target_addr, target_task) = start_tls_websocket_server(acceptor.clone()).await; + + let proxy_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("proxy listener should bind"); + let proxy_addr = proxy_listener + .local_addr() + .expect("proxy listener should have an address"); + let connect_request = Arc::new(Mutex::new(None)); + let proxy_connect_request = Arc::clone(&connect_request); + let proxy_task = tokio::spawn(async move { + let (client, _) = proxy_listener.accept().await.expect("proxy should accept"); + let mut client: Box = if proxy_tls { + Box::new( + acceptor + .accept(client) + .await + .expect("proxy TLS handshake should succeed"), + ) + } else { + Box::new(client) + }; + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + client + .read_exact(&mut byte) + .await + .expect("proxy should read CONNECT request"); + request.push(byte[0]); + } + *proxy_connect_request.lock().await = + Some(String::from_utf8(request).expect("CONNECT request should contain valid UTF-8")); + + let mut target = tokio::net::TcpStream::connect(target_addr) + .await + .expect("proxy should connect to target"); + client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .expect("proxy should acknowledge CONNECT"); + let _ = tokio::io::copy_bidirectional(&mut client, &mut target).await; + }); + + let target_authority = format!("localhost:{}", target_addr.port()); + let proxy_scheme = if proxy_tls { "https" } else { "http" }; + let request = format!("wss://{target_authority}/v1/responses") + .into_client_request() + .expect("websocket request should build"); + let (inner, _) = connect( + request, + WebSocketConfig::default(), + tls_config, + OutboundProxyRoute::Proxy { + url: format!("{proxy_scheme}://localhost:{}", proxy_addr.port()), + }, + ) + .await + .expect("proxied websocket handshake should succeed"); + drop(WebSocketConnection { inner }); + + target_task.await.expect("target task should finish"); + proxy_task.await.expect("proxy task should finish"); + let request = connect_request + .lock() + .await + .clone() + .expect("proxy should record CONNECT request"); + let expected_request_line = format!("CONNECT {target_authority} HTTP/1.1"); + assert_eq!(request.lines().next(), Some(expected_request_line.as_str())); +} + +async fn start_tls_websocket_server(acceptor: TlsAcceptor) -> (SocketAddr, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("target listener should bind"); + let address = listener + .local_addr() + .expect("target listener should have an address"); + let task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("target should accept"); + let stream = acceptor + .accept(stream) + .await + .expect("target TLS handshake should succeed"); + let mut websocket = accept_async(stream) + .await + .expect("target websocket handshake should succeed"); + let _ = websocket.close(None).await; + }); + (address, task) +} + +fn test_tls_configs() -> (Arc, TlsAcceptor) { + ensure_rustls_crypto_provider(); + let CertifiedKey { cert, signing_key } = + generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("test certificate should generate"); + let certificate = cert.der().clone(); + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der())); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate.clone()], private_key) + .expect("test server config should build"); + + let mut roots = RootCertStore::empty(); + roots + .add(certificate) + .expect("test certificate should be trusted"); + let client_config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + + ( + Arc::new(client_config), + TlsAcceptor::from(Arc::new(server_config)), + ) +} diff --git a/codex-rs/websocket-client/src/lib.rs b/codex-rs/websocket-client/src/lib.rs new file mode 100644 index 0000000000..b49046b6a0 --- /dev/null +++ b/codex-rs/websocket-client/src/lib.rs @@ -0,0 +1,133 @@ +//! Proxy-aware WebSocket connection setup shared by Codex API clients. + +mod dialer; + +use std::pin::Pin; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; + +use codex_http_client::BuildCustomCaTransportError; +use codex_http_client::HttpClientFactory; +use codex_http_client::build_rustls_client_config_with_custom_ca; +use futures::Sink; +use futures::Stream; +use rustls::ClientConfig; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::net::TcpStream; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream as TungsteniteStream; +use tokio_tungstenite::tungstenite::Error as WebSocketError; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::client::Request; +use tokio_tungstenite::tungstenite::handshake::client::Response; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; + +/// Connects WebSockets using the outbound proxy policy resolved by application configuration. +/// +/// Construct this from the effective [`HttpClientFactory`] rather than selecting proxy behavior at +/// individual call sites. Each connection resolves its destination through that factory before +/// opening a socket. +#[derive(Clone)] +pub struct WebSocketConnector { + http_client_factory: HttpClientFactory, + tls_config: Arc, +} + +impl WebSocketConnector { + /// Creates a connector using native roots and any configured Codex custom CA bundle. + pub fn new( + http_client_factory: &HttpClientFactory, + ) -> Result { + Ok(Self { + http_client_factory: http_client_factory.clone(), + tls_config: build_rustls_client_config_with_custom_ca()?, + }) + } + + /// Connects a WebSocket after resolving the request destination through the configured proxy + /// policy. + pub async fn connect( + &self, + request: Request, + config: WebSocketConfig, + ) -> Result<(WebSocketConnection, Response), WebSocketError> { + let proxy_route = self + .http_client_factory + .resolve_proxy_route(&request.uri().to_string()); + let (inner, response) = + dialer::connect(request, config, Arc::clone(&self.tls_config), proxy_route).await?; + Ok((WebSocketConnection { inner }, response)) + } +} + +/// An established WebSocket independent of its direct, proxy, and TLS transport layers. +/// +/// This implements [`Stream`] and [`Sink`] so protocol clients can process Tungstenite messages +/// without knowing which concrete network stream route selection produced. +pub struct WebSocketConnection { + inner: ConnectionInner, +} + +impl Stream for WebSocketConnection { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + match &mut self.get_mut().inner { + ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_next(context), + ConnectionInner::Routed(stream) => Pin::new(stream).poll_next(context), + } + } +} + +impl Sink for WebSocketConnection { + type Error = WebSocketError; + + fn poll_ready( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + match &mut self.get_mut().inner { + ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_ready(context), + ConnectionInner::Routed(stream) => Pin::new(stream).poll_ready(context), + } + } + + fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> { + match &mut self.get_mut().inner { + ConnectionInner::TransportDefault(stream) => Pin::new(stream).start_send(message), + ConnectionInner::Routed(stream) => Pin::new(stream).start_send(message), + } + } + + fn poll_flush( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + match &mut self.get_mut().inner { + ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_flush(context), + ConnectionInner::Routed(stream) => Pin::new(stream).poll_flush(context), + } + } + + fn poll_close( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + match &mut self.get_mut().inner { + ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_close(context), + ConnectionInner::Routed(stream) => Pin::new(stream).poll_close(context), + } + } +} + +pub(crate) enum ConnectionInner { + TransportDefault(TungsteniteStream>), + Routed(TungsteniteStream>>), +} + +/// Async network I/O carried through optional proxy and target TLS handshakes. +pub(crate) trait AsyncIo: AsyncRead + AsyncWrite + Send + Unpin {} + +impl AsyncIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {} From c6b124bb31b5bc5db0142f3152e4a2ecd2dd2637 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 8 Jul 2026 13:22:10 -0700 Subject: [PATCH 44/53] [codex] Grant Windows sandbox access to primary runtime (#31574) ## Why Codex Desktop installs its managed primary runtime under `%USERPROFILE%\.cache\codex-runtimes`. Elevated Windows sandbox commands run as dedicated sandbox users. The synchronous runtime ACL refresh repairs read/execute access for the Desktop runtime directories under `%LOCALAPPDATA%\OpenAI\Codex`, but did not include the managed primary runtime cache. As a result, the Desktop app could discover a bundled runtime while a sandboxed command received `ACCESS_DENIED` when reading or executing it. ## What changed - Include `%USERPROFILE%\.cache\codex-runtimes` in the managed runtime paths considered by the Windows sandbox ACL refresh. - Reuse the existing inherited read/execute ACL repair; no write permission is added. - Add Windows-target regression coverage for the runtime path list and the primary-runtime-only case. ## Impact Bundled Python, Node, and native tools remain usable from elevated Windows sandbox sessions without broadening write access or granting access to the rest of the user profile. ## Validation - `just fmt` - `just test -p codex-windows-sandbox` (10/10 host-side tests passed) - Windows-target path tests included for CI --- .../bin/setup_main/win/setup_runtime_bin.rs | 30 ++++++++++++++----- .../setup_main/win/setup_runtime_bin_tests.rs | 28 +++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin_tests.rs diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs index 509670e36b..8b0498d756 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs @@ -10,20 +10,22 @@ use windows_sys::Win32::Security::OBJECT_INHERIT_ACE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; +#[cfg(test)] +#[path = "setup_runtime_bin_tests.rs"] +mod tests; + pub(super) fn ensure_codex_app_runtime_paths_readable( sandbox_group_psid: *mut c_void, refresh_errors: &mut Vec, log: &mut dyn Write, ) -> Result<()> { - let local_app_data = local_app_data_root(); - let Some(local_app_data) = local_app_data else { - return Ok(()); - }; - let read_execute_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; - let codex_root = local_app_data.join("OpenAI").join("Codex"); + let runtime_paths = runtime_paths( + local_app_data_root(), + std::env::var_os("USERPROFILE").map(PathBuf::from), + ); - for runtime_path in [codex_root.join("bin"), codex_root.join("runtimes")] { + for runtime_path in runtime_paths { if !runtime_path.is_dir() { continue; } @@ -86,6 +88,20 @@ pub(super) fn ensure_codex_app_runtime_paths_readable( Ok(()) } +fn runtime_paths(local_app_data: Option, user_profile: Option) -> Vec { + let mut runtime_paths = Vec::new(); + if let Some(local_app_data) = local_app_data { + let codex_root = local_app_data.join("OpenAI").join("Codex"); + runtime_paths.extend([codex_root.join("bin"), codex_root.join("runtimes")]); + } + // The managed primary runtime is installed outside the LocalAppData runtime roots. + if let Some(user_profile) = user_profile { + runtime_paths.push(user_profile.join(".cache").join("codex-runtimes")); + } + + runtime_paths +} + fn local_app_data_root() -> Option { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin_tests.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin_tests.rs new file mode 100644 index 0000000000..a3fa300d0b --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin_tests.rs @@ -0,0 +1,28 @@ +use super::runtime_paths; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +#[test] +fn runtime_paths_include_desktop_and_primary_runtime_roots() { + let local_app_data = PathBuf::from(r"C:\Users\user\AppData\Local"); + let user_profile = PathBuf::from(r"C:\Users\user"); + + assert_eq!( + runtime_paths(Some(local_app_data), Some(user_profile)), + vec![ + PathBuf::from(r"C:\Users\user\AppData\Local\OpenAI\Codex\bin"), + PathBuf::from(r"C:\Users\user\AppData\Local\OpenAI\Codex\runtimes"), + PathBuf::from(r"C:\Users\user\.cache\codex-runtimes"), + ] + ); +} + +#[test] +fn primary_runtime_path_does_not_depend_on_local_app_data() { + let user_profile = PathBuf::from(r"C:\Users\user"); + + assert_eq!( + runtime_paths(/*local_app_data*/ None, Some(user_profile)), + vec![PathBuf::from(r"C:\Users\user\.cache\codex-runtimes")] + ); +} From bdaad6820cd884ea11787477f4c495e4de0a8be5 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Wed, 8 Jul 2026 13:35:31 -0700 Subject: [PATCH 45/53] Reuse MCP tool snapshot within a sampling request (#31292) Follow-up to #30226. ## Why #30226 makes Apps World State inspect the MCP tool list, while tool-router construction reads the same list again later in the sampling request. `list_all_tools()` walks the MCP clients and may reconnect or wait for tools, so doing that work twice adds latency and lets context and tool construction observe different MCP states for one request. ## What - Add a lazy MCP tool snapshot to `StepContext`. - Reuse that snapshot for Apps World State and tool-router construction. - Let each new `StepContext` refresh naturally for the next sampling request, without manager-level caching or invalidation. ## Testing - `just test -p codex-core apps_instructions` - `just test -p codex-core apps_guidance_appears_after_background_recovery_within_a_turn` --- codex-rs/core/src/session/step_context.rs | 11 +++++++++++ codex-rs/core/src/session/turn.rs | 8 ++++---- codex-rs/core/src/session/world_state.rs | 4 ++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs index 3210eb0286..1d7316d914 100644 --- a/codex-rs/core/src/session/step_context.rs +++ b/codex-rs/core/src/session/step_context.rs @@ -5,6 +5,8 @@ use crate::environment_selection::TurnEnvironmentSnapshot; use crate::session::McpRuntimeSnapshot; use crate::session::turn_context::TurnContext; use codex_exec_server::ResolvedSelectedCapabilityRoot; +use codex_mcp::ToolInfo; +use tokio::sync::OnceCell; /// Request-scoped state that may change between model sampling requests. #[derive(Debug)] @@ -15,6 +17,8 @@ pub(crate) struct StepContext { pub(crate) selected_capability_roots: Vec, /// The exact MCP config and manager used to advertise and execute tools for this step. pub(crate) mcp: Arc, + /// The fixed MCP tool list used for this exact sampling request. + mcp_tool_snapshot: OnceCell>, /// The canonical AGENTS.md value observed with this environment snapshot. pub(crate) loaded_agents_md: Option>, } @@ -32,7 +36,14 @@ impl StepContext { environments, selected_capability_roots, mcp, + mcp_tool_snapshot: OnceCell::new(), loaded_agents_md, } } + + pub(crate) async fn mcp_tools(&self) -> &[ToolInfo] { + self.mcp_tool_snapshot + .get_or_init(|| self.mcp.manager().list_all_tools()) + .await + } } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index e231f6af86..6ee3ac2863 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1222,8 +1222,8 @@ pub(crate) async fn built_tools( let turn_context = step_context.turn.as_ref(); let mcp_connection_manager = step_context.mcp.manager(); let has_mcp_servers = mcp_connection_manager.has_servers(); - let all_mcp_tools = mcp_connection_manager - .list_all_tools() + let all_mcp_tools = step_context + .mcp_tools() .or_cancel(cancellation_token) .await?; let loaded_plugins = sess @@ -1236,7 +1236,7 @@ pub(crate) async fn built_tools( let apps_enabled = turn_context.apps_enabled(); let accessible_connectors = - apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&all_mcp_tools)); + apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(all_mcp_tools)); let accessible_connectors_with_enabled_state = accessible_connectors.as_ref().map(|connectors| { connectors::with_app_enabled_state(connectors.clone(), &turn_context.config) @@ -1329,7 +1329,7 @@ pub(crate) async fn built_tools( .await }; let mcp_tool_exposure = build_mcp_tool_exposure( - &all_mcp_tools, + all_mcp_tools, connectors.as_deref(), &turn_context.config, search_tool_enabled(turn_context), diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index a0d93cac83..c7743c3fe1 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -41,9 +41,9 @@ impl Session { } let apps_available = if turn_context.config.include_apps_instructions && turn_context.apps_enabled() { - let tools = step_context.mcp.manager().list_all_tools().await; + let tools = step_context.mcp_tools().await; connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&tools), + connectors::accessible_connectors_from_mcp_tools(tools), &turn_context.config, ) .into_iter() From ba5dd1fd3acd8802325945b88219e69e8b744c40 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 8 Jul 2026 13:42:06 -0700 Subject: [PATCH 46/53] feat(core): emit canonical hook prompt items (#31630) ## Description This PR moves hook prompts onto the canonical `TurnItem` lifecycle in core. Stop hooks now record their `ResponseItem` through the existing lifecycle path, which emits `ItemStarted` and `ItemCompleted`. App-server consumes those events directly instead of deriving a hook prompt from `RawResponseItem`. ## Why Hook prompts were the only `ThreadItem` app-server synthesized from `RawResponseItem`. This brings them in line with other core-owned turn items while preserving legacy rollout replay. ## What changed - Route stop-hook prompts through `record_response_item_and_emit_turn_item`. - Materialize canonical hook prompts in `ThreadHistoryBuilder`. - Remove `RawResponseItem` to `ThreadItem` synthesis while preserving legacy rollout replay. - Add focused coverage for lifecycle emission and canonical and legacy history materialization. --- .../src/protocol/thread_history.rs | 33 +++++- .../app-server/src/bespoke_event_handling.rs | 100 ------------------ codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/thread_lifecycle.rs | 16 +-- codex-rs/core/src/session/tests.rs | 40 +++++++ codex-rs/core/src/session/turn.rs | 4 +- 6 files changed, 77 insertions(+), 117 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 9f7ffa4447..ca8487b792 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -600,6 +600,7 @@ impl ThreadHistoryBuilder { let should_upsert = match item { codex_protocol::items::TurnItem::Plan(plan) => !plan.text.is_empty(), codex_protocol::items::TurnItem::Sleep(_) + | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::CommandExecution(_) | codex_protocol::items::TurnItem::DynamicToolCall(_) | codex_protocol::items::TurnItem::CollabAgentToolCall(_) @@ -608,7 +609,6 @@ impl ThreadHistoryBuilder { | codex_protocol::items::TurnItem::EnteredReviewMode(_) | codex_protocol::items::TurnItem::ExitedReviewMode(_) => true, codex_protocol::items::TurnItem::UserMessage(_) - | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::AgentMessage(_) | codex_protocol::items::TurnItem::Reasoning(_) | codex_protocol::items::TurnItem::WebSearch(_) @@ -4027,6 +4027,37 @@ mod tests { ); } + #[test] + fn canonical_hook_prompt_completion_updates_turn_history() { + let hook_prompt = CoreTurnItem::HookPrompt(codex_protocol::items::HookPromptItem { + id: "hook-prompt-1".into(), + fragments: vec![CoreHookPromptFragment::from_single_hook( + "Retry with tests.", + "hook-run-1", + )], + }); + let expected_item = ThreadItem::from(hook_prompt.clone()); + let mut builder = ThreadHistoryBuilder::new(); + builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })); + builder.handle_event(&EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-a".into(), + item: hook_prompt, + completed_at_ms: 0, + })); + + assert_eq!( + builder.active_turn_snapshot().expect("active turn").items, + vec![expected_item] + ); + } + #[test] fn ignores_plain_user_response_items_in_rollout_replay() { let items = vec![ diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index db5e505eb4..829aa3f126 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -89,7 +89,6 @@ use codex_core::ThreadManager; use codex_protocol::ThreadId; use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool; use codex_protocol::items::TurnItem as CoreTurnItem; -use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -1022,13 +1021,6 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::RawResponseItem(raw_response_item_event) => { - maybe_emit_hook_prompt_item_completed( - conversation_id, - &event_turn_id, - &raw_response_item_event.item, - &outgoing, - ) - .await; maybe_emit_raw_response_item_completed( conversation_id, &event_turn_id, @@ -1406,45 +1398,6 @@ async fn maybe_emit_raw_response_item_completed( .await; } -pub(crate) async fn maybe_emit_hook_prompt_item_completed( - conversation_id: ThreadId, - turn_id: &str, - item: &codex_protocol::models::ResponseItem, - outgoing: &ThreadScopedOutgoingMessageSender, -) { - let codex_protocol::models::ResponseItem::Message { - role, content, id, .. - } = item - else { - return; - }; - - if role != "user" { - return; - } - - let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { - return; - }; - - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: turn_id.to_string(), - completed_at_ms: now_unix_timestamp_ms(), - item: ThreadItem::HookPrompt { - id: hook_prompt.id, - fragments: hook_prompt - .fragments - .into_iter() - .map(codex_app_server_protocol::HookPromptFragment::from) - .collect(), - }, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; -} - async fn find_and_remove_turn_summary( _conversation_id: ThreadId, thread_state: &Arc>, @@ -2104,10 +2057,8 @@ mod tests { use codex_protocol::AgentPath; use codex_protocol::items::DynamicToolCallItem; use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus; - use codex_protocol::items::HookPromptFragment; use codex_protocol::items::SubAgentActivityItem; use codex_protocol::items::TurnItem as CoreTurnItem; - use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile; @@ -3993,55 +3944,4 @@ mod tests { assert!(rx.try_recv().is_err(), "no extra messages expected"); Ok(()) } - - #[tokio::test] - async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { - let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new( - tx, - codex_analytics::AnalyticsEventsClient::disabled(), - )); - let conversation_id = ThreadId::new(); - let outgoing = ThreadScopedOutgoingMessageSender::new( - outgoing, - vec![ConnectionId(1)], - conversation_id, - ); - let item = build_hook_prompt_message(&[ - HookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"), - HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), - ]) - .expect("hook prompt message"); - - maybe_emit_hook_prompt_item_completed(conversation_id, "turn-1", &item, &outgoing).await; - - let msg = recv_broadcast_message(&mut rx).await?; - match msg { - OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted( - notification, - )) => { - assert_eq!(notification.thread_id, conversation_id.to_string()); - assert_eq!(notification.turn_id, "turn-1"); - assert_eq!( - notification.item, - ThreadItem::HookPrompt { - id: notification.item.id().to_string(), - fragments: vec![ - codex_app_server_protocol::HookPromptFragment { - text: "Retry with tests.".into(), - hook_run_id: "hook-run-1".into(), - }, - codex_app_server_protocol::HookPromptFragment { - text: "Then summarize cleanly.".into(), - hook_run_id: "hook-run-2".into(), - }, - ], - } - ); - } - other => bail!("unexpected message: {other:?}"), - } - assert!(rx.try_recv().is_err(), "no extra messages expected"); - Ok(()) - } } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 42ec2063b4..a7a1f6ce81 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -1,5 +1,4 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; -use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; use crate::config_manager::ConfigManager; diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 82a49bae0b..784cd51add 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -316,6 +316,9 @@ pub(super) async fn ensure_listener_task_running( thread_state.track_current_turn_event(&event.id, &event.msg); thread_state.experimental_raw_events }; + if matches!(&event.msg, EventMsg::RawResponseItem(_)) && !raw_events_enabled { + continue; + } let subscribed_connection_ids = thread_state_manager .subscribed_connection_ids(conversation_id) .await; @@ -325,19 +328,6 @@ pub(super) async fn ensure_listener_task_running( conversation_id, ); - if let EventMsg::RawResponseItem(raw_response_item_event) = &event.msg - && !raw_events_enabled - { - maybe_emit_hook_prompt_item_completed( - conversation_id, - &event.id, - &raw_response_item_event.item, - &thread_outgoing, - ) - .await; - continue; - } - apply_bespoke_event_handling( event.clone(), conversation_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ac8f9d049e..90f43f2bc3 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -112,6 +112,8 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::items::HookPromptFragment; +use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::InternalChatMessageMetadataPassthrough; @@ -1763,6 +1765,44 @@ async fn record_conversation_items_stamps_missing_turn_id_and_preserves_existing ); } +#[tokio::test] +async fn record_response_item_and_emit_turn_item_emits_hook_prompt_lifecycle() { + let (session, turn_context, rx) = make_session_and_context_with_rx().await; + let response_item = build_hook_prompt_message(&[HookPromptFragment::from_single_hook( + "Retry with tests.", + "hook-run-1", + )]) + .expect("hook prompt message"); + let response_item_id = response_item.id().expect("hook prompt id").to_string(); + + session + .record_response_item_and_emit_turn_item(&turn_context, response_item) + .await; + + let raw_response = rx.recv().await.expect("raw response item event"); + assert!(matches!(raw_response.msg, EventMsg::RawResponseItem(_))); + + let started = rx.recv().await.expect("started hook prompt event"); + assert!(matches!( + started.msg, + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::HookPrompt(item), + .. + }) if item.id == response_item_id + )); + + let completed = rx.recv().await.expect("completed hook prompt event"); + assert!(matches!( + completed.msg, + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::HookPrompt(item), + .. + }) if item.id == response_item_id + )); + + assert!(rx.try_recv().is_err(), "no extra events expected"); +} + #[tokio::test] async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { let (mut session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 6ee3ac2863..41098b2c1e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -382,9 +382,9 @@ pub(crate) async fn run_turn( if let Some(hook_prompt_message) = build_hook_prompt_message(&stop_outcome.continuation_fragments) { - sess.record_conversation_items( + sess.record_response_item_and_emit_turn_item( &turn_context, - std::slice::from_ref(&hook_prompt_message), + hook_prompt_message, ) .await; stop_hook_active = true; From bff9c4945a7ba83e5c9b1e5787ce7b0c5f0c613c Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 8 Jul 2026 13:53:18 -0700 Subject: [PATCH 47/53] feat: change amazon Bedrock GPT-5.6 display names (#31636) ## Why Amazon Bedrock's GPT-5.6 variants currently appear as only `Sol`, `Terra`, and `Luna`. Those labels omit the model family and version, making them ambiguous in model lists and inconsistent with the naming of other GPT models. ## What changed - Rename the three Bedrock model display names to `GPT-5.6 Sol`, `GPT-5.6 Terra`, and `GPT-5.6 Luna`. - Strengthen the Bedrock model-manager test to verify both model IDs and their propagated display names. Model IDs, ordering, priorities, reasoning support, and default selection are unchanged. --- .../src/amazon_bedrock/catalog.rs | 30 +++++++++++++------ codex-rs/model-provider/src/provider.rs | 16 +++++----- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 95e522e0c4..48c24ad5c8 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -19,26 +19,28 @@ pub(crate) fn static_model_catalog() -> ModelsResponse { gpt_5_bedrock_model( GPT_5_5_OPENAI_MODEL_ID, AMAZON_BEDROCK_GPT_5_5_MODEL_ID, + "GPT-5.5", /*priority*/ 0, ), gpt_5_bedrock_model( GPT_5_4_OPENAI_MODEL_ID, AMAZON_BEDROCK_GPT_5_4_MODEL_ID, + "GPT-5.4", /*priority*/ 1, ), gpt_5_6_bedrock_model( AMAZON_BEDROCK_GPT_5_6_SOL_MODEL_ID, - "Sol", + "GPT-5.6 Sol", /*priority*/ 2, ), gpt_5_6_bedrock_model( AMAZON_BEDROCK_GPT_5_6_TERRA_MODEL_ID, - "Terra", + "GPT-5.6 Terra", /*priority*/ 3, ), gpt_5_6_bedrock_model( AMAZON_BEDROCK_GPT_5_6_LUNA_MODEL_ID, - "Luna", + "GPT-5.6 Luna", /*priority*/ 4, ), ], @@ -55,9 +57,15 @@ pub(crate) fn with_default_only_service_tier(mut catalog: ModelsResponse) -> Mod catalog } -fn gpt_5_bedrock_model(openai_slug: &str, bedrock_slug: &str, priority: i32) -> ModelInfo { +fn gpt_5_bedrock_model( + openai_slug: &str, + bedrock_slug: &str, + display_name: &str, + priority: i32, +) -> ModelInfo { let mut model = bundled_openai_model(openai_slug); model.slug = bedrock_slug.to_string(); + model.display_name = display_name.to_string(); model.priority = priority; model.context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); model.max_context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); @@ -67,8 +75,12 @@ fn gpt_5_bedrock_model(openai_slug: &str, bedrock_slug: &str, priority: i32) -> } fn gpt_5_6_bedrock_model(bedrock_slug: &str, display_name: &str, priority: i32) -> ModelInfo { - let mut model = gpt_5_bedrock_model(GPT_5_5_OPENAI_MODEL_ID, bedrock_slug, priority); - model.display_name = display_name.to_string(); + let mut model = gpt_5_bedrock_model( + GPT_5_5_OPENAI_MODEL_ID, + bedrock_slug, + display_name, + priority, + ); model .supported_reasoning_levels .push(ReasoningEffortPreset { @@ -148,9 +160,9 @@ mod tests { .expect("Bedrock catalog should include GPT-5.5"); for (slug, display_name, priority) in [ - (AMAZON_BEDROCK_GPT_5_6_SOL_MODEL_ID, "Sol", 2), - (AMAZON_BEDROCK_GPT_5_6_TERRA_MODEL_ID, "Terra", 3), - (AMAZON_BEDROCK_GPT_5_6_LUNA_MODEL_ID, "Luna", 4), + (AMAZON_BEDROCK_GPT_5_6_SOL_MODEL_ID, "GPT-5.6 Sol", 2), + (AMAZON_BEDROCK_GPT_5_6_TERRA_MODEL_ID, "GPT-5.6 Terra", 3), + (AMAZON_BEDROCK_GPT_5_6_LUNA_MODEL_ID, "GPT-5.6 Luna", 4), ] { let mut expected = gpt_5_5.clone(); expected.slug = slug.to_string(); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 2b8cb67b97..f2fe59e6c9 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -657,20 +657,20 @@ mod tests { provider.models_manager(test_codex_home(), /*config_model_catalog*/ None); let catalog = manager.raw_model_catalog(RefreshStrategy::Online).await; - let model_ids = catalog + let models = catalog .models .iter() - .map(|model| model.slug.as_str()) + .map(|model| (model.slug.as_str(), model.display_name.as_str())) .collect::>(); assert_eq!( - model_ids, + models, vec![ - "openai.gpt-5.5", - "openai.gpt-5.4", - "openai.gpt-5.6-sol", - "openai.gpt-5.6-terra", - "openai.gpt-5.6-luna", + ("openai.gpt-5.5", "GPT-5.5"), + ("openai.gpt-5.4", "GPT-5.4"), + ("openai.gpt-5.6-sol", "GPT-5.6 Sol"), + ("openai.gpt-5.6-terra", "GPT-5.6 Terra"), + ("openai.gpt-5.6-luna", "GPT-5.6 Luna"), ] ); From 602dbb42dcc9504252525bb01182771f39c68cf1 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 8 Jul 2026 13:53:36 -0700 Subject: [PATCH 48/53] core: stop emitting legacy command events directly (#31629) ## Description This PR removes the last path in core that emits `ExecCommandBegin` / `ExecCommandEnd` directly. Every command execution now starts and completes through canonical `ItemStarted` / `ItemCompleted(TurnItem::CommandExecution)`. The existing `HasLegacyEvent` compatibility layer still fans out Begin/End afterward, so raw core event consumers and legacy rollout replay keep seeing the same events. `UnifiedExecInteraction` is dormant today. Live unified exec uses `UnifiedExecStartup` for command lifecycle and `TerminalInteraction` for `write_stdin` and polling, so this is code cleanup rather than a current product behavior change. The main win is the code-level invariant where all core flows emit `TurnItem` instead of legacy events. ## What changed - Removed the `UnifiedExecInteraction` branches that emitted legacy command events directly. - Routed every command source through the existing canonical `CommandExecution` lifecycle and compatibility fanout. --- codex-rs/core/src/tools/events.rs | 48 ------------------------------- 1 file changed, 48 deletions(-) diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 5b30c6a156..628e6ce9bc 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -3,7 +3,6 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::sandboxing::ToolError; -use crate::turn_timing::now_unix_timestamp_ms; use codex_apply_patch::AppliedPatchDelta; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -14,8 +13,6 @@ use codex_protocol::items::FileChangeItem; use codex_protocol::items::TurnItem; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::FileChange; @@ -104,25 +101,6 @@ pub(crate) async fn emit_exec_command_begin( interaction_input: Option, process_id: Option<&str>, ) { - if matches!(source, ExecCommandSource::UnifiedExecInteraction) { - ctx.session - .send_event( - ctx.turn, - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: ctx.call_id.to_string(), - process_id: process_id.map(str::to_owned), - turn_id: ctx.turn.sub_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - command: command.to_vec(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.to_vec(), - source, - interaction_input, - }), - ) - .await; - return; - } ctx.session .emit_turn_item_started( ctx.turn, @@ -568,32 +546,6 @@ async fn emit_exec_end( exec_input: ExecCommandInput<'_>, exec_result: ExecCommandResult, ) { - if matches!(exec_input.source, ExecCommandSource::UnifiedExecInteraction) { - ctx.session - .send_event( - ctx.turn, - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: ctx.call_id.to_string(), - process_id: exec_input.process_id.map(str::to_owned), - turn_id: ctx.turn.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - command: exec_input.command.to_vec(), - cwd: exec_input.cwd.clone(), - parsed_cmd: exec_input.parsed_cmd.to_vec(), - source: exec_input.source, - interaction_input: exec_input.interaction_input.map(str::to_owned), - stdout: exec_result.stdout, - stderr: exec_result.stderr, - aggregated_output: exec_result.aggregated_output, - exit_code: exec_result.exit_code, - duration: exec_result.duration, - formatted_output: exec_result.formatted_output, - status: exec_result.status, - }), - ) - .await; - return; - } ctx.session .emit_turn_item_completed( ctx.turn, From e621d7df8c4571e0b996a29095ef0de5bc93af5c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 8 Jul 2026 14:06:15 -0700 Subject: [PATCH 49/53] core: preserve Responses WebSockets with system proxy (#31441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Responses WebSockets are the normal lower-latency transport for WebSocket-capable providers. They must not bypass an OS-selected proxy when `features.respect_system_proxy` is enabled, but disabling WebSockets whenever the feature is enabled would impose a substantial performance penalty. Merged PR #31622 introduced the reusable proxy-aware WebSocket transport. This PR makes the Responses API its first consumer so the existing fast path uses the same effective proxy and trust policy as HTTP. ## What changed - Register `codex-websocket-client` as a workspace dependency and use it from `codex-api`. - Feed the shared crate’s route-independent `WebSocketConnection` into the existing Responses message pump. - Require a configured `HttpClientFactory` for normal Responses WebSocket connections and the CLI doctor probe, so neither path can open a connection without consulting the effective proxy policy. - Pass the session factory from `core` and the effective configuration factory from `doctor`. - Add an end-to-end Responses test that enables `RespectSystemProxy`, asserts the resolved policy, completes a turn over WebSocket, and verifies the connection and request counts. - Keep the existing Responses protocol handling, ping/pong pump, and session-scoped HTTP fallback unchanged. The DNS, proxy, TLS, custom-CA, and Happy Eyeballs implementation and its transport tests live in merged PR #31622. This PR deliberately contains only the Responses integration and does not duplicate that transport code. ## Review guide 1. `codex-rs/codex-api/src/endpoint/responses_websocket.rs` constructs the shared connector and adapts its uniform stream to the existing pump. 2. `codex-rs/core/src/client.rs` supplies the session-scoped factory for production Responses connections. 3. `codex-rs/cli/src/doctor.rs` supplies the effective configuration factory to the handshake probe. 4. `codex-rs/core/tests/suite/client_websockets.rs` covers the enabled-feature path end to end. ## Test plan - `cargo check --tests -p codex-api -p codex-core -p codex-cli` - `just test -p codex-api` - `just test -p codex-core responses_websocket_streams_with_system_proxy_feature` - `cargo shear` - `just bazel-lock-check` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/31441). * #31637 * #31431 * #31363 * #31362 * #31361 * __->__ #31441 --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/cli/src/doctor.rs | 2 + codex-rs/codex-api/Cargo.toml | 1 + .../src/endpoint/responses_websocket.rs | 42 ++++++-------- codex-rs/core/src/client.rs | 1 + .../core/tests/suite/client_websockets.rs | 56 ++++++++++++++++++- 7 files changed, 77 insertions(+), 27 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9856ef9cd3..3d98479749 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1940,6 +1940,7 @@ dependencies = [ "codex-http-client", "codex-protocol", "codex-utils-rustls-provider", + "codex-websocket-client", "eventsource-stream", "futures", "http 1.4.0", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0085b663ef..7b153dfaa0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -168,6 +168,7 @@ codex-code-mode = { path = "code-mode" } codex-code-mode-protocol = { path = "code-mode-protocol" } codex-home = { path = "codex-home" } codex-http-client = { path = "http-client" } +codex-websocket-client = { path = "websocket-client" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } codex-connectors-extension = { path = "ext/connectors" } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 2e9c0094c4..7f06a611bc 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2383,9 +2383,11 @@ async fn websocket_reachability_check( HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), ); let client = ResponsesWebsocketClient::new(api_provider, api_auth); + let http_client_factory = config.http_client_factory(); match tokio::time::timeout( provider.websocket_connect_timeout(), client.probe_handshake( + &http_client_factory, extra_headers, default_headers(), WEBSOCKET_IMMEDIATE_CLOSE_GRACE, diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 084b058e12..21f6b67425 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -13,6 +13,7 @@ codex-client = { workspace = true } codex-http-client = { workspace = true } codex-protocol = { workspace = true } codex-utils-rustls-provider = { workspace = true } +codex-websocket-client = { workspace = true } futures = { workspace = true } http = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index cce056f638..88e7fbb9b4 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -11,8 +11,9 @@ use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; use codex_client::TransportError; -use codex_http_client::maybe_build_rustls_client_config_with_custom_ca; -use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use codex_http_client::HttpClientFactory; +use codex_websocket_client::WebSocketConnection; +use codex_websocket_client::WebSocketConnector; use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; @@ -25,14 +26,10 @@ use serde_json::map::Map as JsonMap; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; -use tokio::net::TcpStream; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::Instant; -use tokio_tungstenite::MaybeTlsStream; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -62,7 +59,7 @@ enum WsCommand { } impl WsStream { - fn new(inner: WebSocketStream>) -> Self { + fn new(inner: WebSocketConnection) -> Self { let (tx_command, mut rx_command) = mpsc::channel::(32); let (tx_message, rx_message) = mpsc::unbounded_channel::>(); @@ -334,6 +331,7 @@ impl ResponsesWebsocketClient { )] pub async fn connect( &self, + http_client_factory: &HttpClientFactory, extra_headers: HeaderMap, default_headers: HeaderMap, turn_state: Option>>, @@ -349,7 +347,7 @@ impl ResponsesWebsocketClient { self.auth.add_auth_headers(&mut headers); let (stream, _status, server_reasoning_included, models_etag, server_model) = - connect_websocket(ws_url, headers, turn_state.clone()).await?; + connect_websocket(ws_url, headers, http_client_factory, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, self.provider.stream_idle_timeout, @@ -369,6 +367,7 @@ impl ResponsesWebsocketClient { /// a usable connection from a policy rejection that closes right away. pub async fn probe_handshake( &self, + http_client_factory: &HttpClientFactory, extra_headers: HeaderMap, default_headers: HeaderMap, immediate_close_timeout: Duration, @@ -383,7 +382,13 @@ impl ResponsesWebsocketClient { self.auth.add_auth_headers(&mut headers); let (mut stream, status, reasoning_included, models_etag, server_model) = - connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?; + connect_websocket( + ws_url.clone(), + headers, + http_client_factory, + /*turn_state*/ None, + ) + .await?; let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next()) .await .ok() @@ -437,9 +442,9 @@ fn merge_request_headers( async fn connect_websocket( url: Url, headers: HeaderMap, + http_client_factory: &HttpClientFactory, turn_state: Option>>, ) -> Result<(WsStream, StatusCode, bool, Option, Option), ApiError> { - ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); let mut request = url @@ -448,20 +453,9 @@ async fn connect_websocket( .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; request.headers_mut().extend(headers); - // Secure websocket traffic needs the same custom-CA policy as reqwest-based HTTPS traffic. - // If a Codex-specific CA bundle is configured, build an explicit rustls connector so this - // websocket path does not fall back to tungstenite's default native-roots-only behavior. - let connector = maybe_build_rustls_client_config_with_custom_ca() - .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))? - .map(tokio_tungstenite::Connector::Rustls); - - let response = connect_async_tls_with_config( - request, - Some(websocket_config()), - false, // `false` means "do not disable Nagle", which is tungstenite's recommended default. - connector, - ) - .await; + let connector = WebSocketConnector::new(http_client_factory) + .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))?; + let response = connector.connect(request, websocket_config()).await; let (stream, response) = match response { Ok((stream, response)) => { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8c98a3372e..2bd4ae3bcf 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1006,6 +1006,7 @@ impl ModelClient { let result = match tokio::time::timeout( websocket_connect_timeout, ApiWebSocketResponsesClient::new(api_provider, api_auth).connect( + &self.http_client_factory, headers, codex_login::default_client::default_headers(), /*turn_state*/ None, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 05a93807b2..71d0965ef9 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -8,6 +8,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_features::Feature; +use codex_http_client::OutboundProxyPolicy; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuthPolicy; use codex_model_provider_info::ModelProviderInfo; @@ -102,6 +103,7 @@ fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTr struct WebsocketTestHarness { _codex_home: TempDir, client: ModelClient, + outbound_proxy_policy: OutboundProxyPolicy, session_id: SessionId, thread_id: ThreadId, model_info: ModelInfo, @@ -230,6 +232,38 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_streams_with_system_proxy_feature() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ]]]) + .await; + + let harness = websocket_harness_with_provider_options( + websocket_provider(&server), + /*runtime_metrics_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, + /*enabled_features*/ &[Feature::RespectSystemProxy], + ) + .await; + assert_eq!( + harness.outbound_proxy_policy, + OutboundProxyPolicy::RespectSystemProxy + ); + let mut client_session = harness.client.new_session(); + let prompt = prompt_with_input(vec![message_item("hello")]); + + stream_until_complete(&mut client_session, &harness, &prompt).await; + + assert_eq!(server.handshakes().len(), 1); + assert_eq!(server.single_connection().len(), 1); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); @@ -389,8 +423,10 @@ async fn responses_websocket_request_prewarm_reuses_connection() { let mut provider = websocket_provider(&server); provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; let harness = websocket_harness_with_provider_options( - provider, /*runtime_metrics_enabled*/ true, + provider, + /*runtime_metrics_enabled*/ true, /*concurrent_reasoning_summaries_enabled*/ true, + /*enabled_features*/ &[], ) .await; let mut client_session = harness.client.new_session(); @@ -1005,8 +1041,10 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { let mut provider = websocket_provider(&server); provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; let harness = websocket_harness_with_provider_options( - provider, /*runtime_metrics_enabled*/ false, + provider, + /*runtime_metrics_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, + /*enabled_features*/ &[], ) .await; @@ -2216,6 +2254,7 @@ async fn websocket_harness_with_options( websocket_provider(server), runtime_metrics_enabled, /*concurrent_reasoning_summaries_enabled*/ false, + /*enabled_features*/ &[], ) .await } @@ -2224,6 +2263,7 @@ async fn websocket_harness_with_provider_options( provider: ModelProviderInfo, runtime_metrics_enabled: bool, concurrent_reasoning_summaries_enabled: bool, + enabled_features: &[Feature], ) -> WebsocketTestHarness { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home).await; @@ -2240,6 +2280,15 @@ async fn websocket_harness_with_provider_options( .enable(Feature::ConcurrentReasoningSummaries) .expect("test config should allow feature update"); } + for feature in enabled_features { + config + .features + .enable(*feature) + .expect("test config should allow feature update"); + } + config.respect_system_proxy = config.features.enabled(Feature::RespectSystemProxy); + let http_client_factory = config.http_client_factory(); + let outbound_proxy_policy = http_client_factory.outbound_proxy_policy(); let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); let thread_id = ThreadId::new(); @@ -2284,12 +2333,13 @@ async fn websocket_harness_with_provider_options( .features .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, - config.http_client_factory(), + http_client_factory, ); WebsocketTestHarness { _codex_home: codex_home, client, + outbound_proxy_policy, session_id, thread_id, model_info, From bd5c860abe0f492d96f4f868717f8c377a538dd3 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 8 Jul 2026 14:06:37 -0700 Subject: [PATCH 50/53] ci: route build IO through Dev Drives (#31357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Windows Cargo and Bazel jobs spend significant time in filesystem-heavy build and cache directories. Route those directories through one CI build root so Windows can use its Dev Drive and Unix can use a stable cache root. ## What - Have `setup-ci` define `CI_BUILD_ROOT`, `CARGO_TARGET_DIR`, Bazel cache/output paths, and temp paths. - Require Windows to find or provision a verified Dev Drive instead of falling back to `C:`. - Pass the shared Bazel output base to `setup-bazel` so its explicit `output_base` does not defeat Dev Drive routing. - Point nextest, release, and V8 source-build paths at the shared environment contract. ## Benchmark results One-off cold-cache WPR/ETW traces show the explicit Bazel output-base routing removes the dominant `C:` traffic: | sample | `C:\_bazel` | summed `C:` traffic | traced test step | |---|---:|---:|---:| | shard 1 before | 62.2 GiB | 85.2 GiB | 16m22s | | shard 1 updated | 0 | 16.5 GiB | 12m05s | | shard 3 before | 67.2 GiB | 84.6 GiB | 16m48s | | shard 3 updated | 0 | 13.5 GiB | 11m08s | For a cold x64 V8 source build, the retained build-tail sample showed `D:\cargo-target` at ~1.29 GiB while measured `C:` roots totaled ~0.45 GiB (`C:\Users` ~0.33 GiB, `C:\Program Files` ~0.06 GiB, `C:\Windows` ~0.03 GiB). The full cold build took 2h20m36s. The Bazel timing improvement is directional because both refreshed shards failed tests. The V8 trace is a bounded build-tail sample, not the full build. All final samples had zero lost ETW events; VHDX traffic was excluded from the optimization ranking. Runs: [baseline Bazel](https://github.com/openai/codex/actions/runs/28911908527), [updated Bazel](https://github.com/openai/codex/actions/runs/28917133701), [V8 build tail](https://github.com/openai/codex/actions/runs/28933626678). ## Manual validation - Ran `just fmt`. - Ran `just test-github-scripts` (35 tests). - Parsed GitHub Actions YAML with `yq`. - Ran `git diff --check`. ## Stack - [#31332](https://github.com/openai/codex/pull/31332) — parameterize Cargo target paths - [#31356](https://github.com/openai/codex/pull/31356) — Windows 2025 runner bump - [#31357](https://github.com/openai/codex/pull/31357) — Dev Drive I/O routing --- .github/actions/setup-bazel-ci/action.yml | 26 ++------ .github/actions/setup-ci/action.yml | 60 +++++++++++++++++-- .github/scripts/run_bazel_with_buildbuddy.py | 59 +++++++++++++----- .github/scripts/setup-dev-drive.ps1 | 37 ++++++------ .../scripts/test_run_bazel_with_buildbuddy.py | 42 +++++++++++++ .github/scripts/test_v8_canary_changes.py | 18 +++--- .github/scripts/v8_canary_changes.py | 2 + .../rust-ci-full-nextest-platform.yml | 11 +--- .github/workflows/sdk.yml | 3 +- .github/workflows/v8-canary.yml | 11 ++-- scripts/test-remote-env.sh | 2 +- 11 files changed, 188 insertions(+), 83 deletions(-) diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml index 57e71fbc78..ac4f5aa97c 100644 --- a/.github/actions/setup-bazel-ci/action.yml +++ b/.github/actions/setup-bazel-ci/action.yml @@ -12,7 +12,8 @@ outputs: runs: using: composite steps: - - uses: ./.github/actions/setup-ci + - id: setup_ci + uses: ./.github/actions/setup-ci - name: Set up Bazel uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 @@ -20,30 +21,15 @@ runs: # and Windows can use the runner's standalone Bazel, ignoring .bazelversion. with: bazelisk-version: 1.28.1 + # setup-bazel writes an explicit output_base, which otherwise overrides + # BAZEL_OUTPUT_USER_ROOT and leaves Bazel's I/O-heavy trees on C:. + output-base: ${{ steps.setup_ci.outputs.bazel-output-base }} - name: Configure Bazel repository cache id: configure_bazel_repository_cache shell: pwsh run: | - # Keep the repository cache under HOME on all runners. Windows `D:\a` - # cache paths match `.bazelrc`, but `actions/cache/restore` currently - # returns HTTP 400 for that path in the Windows clippy job. - $repositoryCachePath = Join-Path $HOME '.cache/bazel-repo-cache' - "repository-cache-path=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "BAZEL_REPOSITORY_CACHE=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Configure Bazel output root (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - # Use the shortest available drive to reduce argv/path length issues, - # but avoid the drive root because some Windows test launchers mis-handle - # MANIFEST paths there. - $hasDDrive = Test-Path 'D:\' - $bazelOutputUserRoot = if ($hasDDrive) { 'D:\b' } else { 'C:\b' } - $repoContentsCache = Join-Path $env:RUNNER_TEMP "bazel-repo-contents-cache-$env:GITHUB_RUN_ID-$env:GITHUB_JOB" - "BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "BAZEL_REPO_CONTENTS_CACHE=$repoContentsCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "repository-cache-path=$env:BAZEL_REPOSITORY_CACHE" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Expose MSVC SDK environment (Windows) if: runner.os == 'Windows' diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml index 23c78a4952..59f8850d27 100644 --- a/.github/actions/setup-ci/action.yml +++ b/.github/actions/setup-ci/action.yml @@ -1,14 +1,66 @@ name: setup-ci description: Prepare common tools and environment shared by CI jobs. +outputs: + bazel-output-base: + description: Filesystem path used for Bazel's output base. + value: ${{ steps.configure_ci_build_paths.outputs.bazel-output-base }} + cargo-target-dir: + description: Filesystem path used for Cargo's target directory. + value: ${{ steps.configure_ci_build_paths.outputs.cargo-target-dir }} runs: using: composite steps: - # TODO(anp): Move this under CI_BUILD_ROOT once all consumers use the - # shared build-path contract. - - name: Configure Cargo target directory + # setup-ci expects either this step or the Unix fallback below to define + # CI_BUILD_ROOT. Windows puts it on a Dev Drive because Cargo and Bazel + # spend significant time reading and writing build/cache trees. + - name: Configure Dev Drive (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: ./.github/scripts/setup-dev-drive.ps1 + + - name: Configure CI build root (Unix) + if: runner.os != 'Windows' shell: bash - run: echo "CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/codex-rs/target}" >> "$GITHUB_ENV" + run: echo "CI_BUILD_ROOT=$HOME/.cache/codex-ci" >> "$GITHUB_ENV" + + - name: Configure CI build paths + id: configure_ci_build_paths + shell: bash + run: | + set -euo pipefail + # setup-bazel passes output_base explicitly, so keep both it and the + # user root under the shared build root. Keep these directory names tiny + # on every platform so Windows Bazel paths do not overflow argv or + # confuse test MANIFEST handling. + bazel_output_base="$CI_BUILD_ROOT/o" + bazel_output_user_root="$CI_BUILD_ROOT/b" + bazel_repository_cache="$CI_BUILD_ROOT/bazel-repository-cache" + bazel_repo_contents_cache="$CI_BUILD_ROOT/bazel-repo-contents-cache-$GITHUB_RUN_ID-$GITHUB_JOB" + cargo_target_dir="$CI_BUILD_ROOT/cargo-target" + tmp="$CI_BUILD_ROOT/tmp" + + build_dirs=( + "$bazel_output_base" + "$bazel_output_user_root" + "$bazel_repository_cache" + "$bazel_repo_contents_cache" + "$cargo_target_dir" + "$tmp" + ) + mkdir -p "${build_dirs[@]}" + echo "bazel-output-base=$bazel_output_base" >> "$GITHUB_OUTPUT" + echo "cargo-target-dir=$cargo_target_dir" >> "$GITHUB_OUTPUT" + + { + echo "BAZEL_OUTPUT_BASE=$bazel_output_base" + echo "BAZEL_OUTPUT_USER_ROOT=$bazel_output_user_root" + echo "BAZEL_REPOSITORY_CACHE=$bazel_repository_cache" + echo "BAZEL_REPO_CONTENTS_CACHE=$bazel_repo_contents_cache" + echo "CARGO_TARGET_DIR=$cargo_target_dir" + echo "TEMP=$tmp" + echo "TMP=$tmp" + } >> "$GITHUB_ENV" - name: Prefer the Git CLI for Cargo git dependencies shell: bash diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py index 4503b4fda3..afbf0b656e 100755 --- a/.github/scripts/run_bazel_with_buildbuddy.py +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -131,25 +131,54 @@ def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]: def bazel_args_with_remote_config( args: Sequence[str], env: Mapping[str, str] ) -> list[str]: + command_idx = next( + (idx for idx, arg in enumerate(args) if not arg.startswith("-")), + None, + ) + if command_idx is None: + raise ValueError("expected a Bazel command") + config = remote_config(args, env) if config is None: - return bazel_args_without_remote_execution(args) + configured_args = bazel_args_without_remote_execution(args) + else: + # `remote_config()` returns a configuration only when this key is present. + api_key = env["BUILDBUDDY_API_KEY"] + remote_args = [ + f"--config={config}", + f"--remote_header=x-buildbuddy-api-key={api_key}", + ] - # `remote_config()` returns a configuration only when this key is present. - api_key = env["BUILDBUDDY_API_KEY"] - remote_args = [ - f"--config={config}", - f"--remote_header=x-buildbuddy-api-key={api_key}", + # Insert immediately after the Bazel command. This keeps wrapper-added + # options out of positional payloads and lets later CI configs override + # shared RBE defaults such as the Windows cross-compilation exec platforms. + configured_args = [ + *args[: command_idx + 1], + *remote_args, + *args[command_idx + 1 :], + ] + + try: + separator_idx = configured_args.index("--") + except ValueError: + separator_idx = len(configured_args) + + cache_args = [ + f"{option_prefix}{env[env_name]}" + for env_name, option_prefix in ( + ("BAZEL_REPO_CONTENTS_CACHE", "--repo_contents_cache="), + ("BAZEL_REPOSITORY_CACHE", "--repository_cache="), + ) + if env.get(env_name) + and not any( + arg.startswith(option_prefix) for arg in configured_args[:separator_idx] + ) + ] + return [ + *configured_args[:separator_idx], + *cache_args, + *configured_args[separator_idx:], ] - - # Insert immediately after the Bazel command. This keeps wrapper-added - # options out of positional payloads and lets later CI configs override - # shared RBE defaults such as the Windows cross-compilation exec platforms. - insertion_idx = next( - (idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")), - len(args), - ) - return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]] def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: diff --git a/.github/scripts/setup-dev-drive.ps1 b/.github/scripts/setup-dev-drive.ps1 index 2b94e1b66f..dfd2ea1f0a 100644 --- a/.github/scripts/setup-dev-drive.ps1 +++ b/.github/scripts/setup-dev-drive.ps1 @@ -1,14 +1,15 @@ # Configure a fast drive for Windows CI jobs. # # GitHub-hosted Windows runners do not always expose a secondary D: volume. When -# they do not, try to create a Dev Drive VHD and fall back to C: if the runner -# image does not allow that provisioning path. +# they do not, create a Dev Drive VHD. CI depends on this path for its +# build directories where CI spends significant time doing I/O, so fail the +# job if no real Dev Drive is available. -function Use-FallbackDrive { - param([string]$Reason) +function Test-DevDrive { + param([string]$Drive) - Write-Warning "$Reason Falling back to C:" - return "C:" + & fsutil devdrv query $Drive *> $null + return $LASTEXITCODE -eq 0 } function Invoke-BestEffort { @@ -21,10 +22,14 @@ function Invoke-BestEffort { } } -if (Test-Path "D:\") { - Write-Output "Using existing drive at D:" +if ((Test-Path "D:\") -and (Test-DevDrive "D:")) { + Write-Output "Using existing Dev Drive at D:" $Drive = "D:" } else { + if (Test-Path "D:\") { + Write-Output "Existing D: volume is not a Dev Drive; provisioning a new Dev Drive VHD." + } + try { $VhdPath = Join-Path $env:RUNNER_TEMP "codex-dev-drive.vhdx" $SizeBytes = 64GB @@ -42,21 +47,17 @@ if (Test-Path "D:\") { $Drive = "$($Volume.DriveLetter):" + if (-not (Test-DevDrive $Drive)) { + throw "Provisioned volume at $Drive did not pass Dev Drive verification." + } + Invoke-BestEffort { fsutil devdrv trust $Drive } "Trusting Dev Drive $Drive" Invoke-BestEffort { fsutil devdrv enable /disallowAv } "Disabling AV filter attachment for Dev Drives" - Invoke-BestEffort { fsutil devdrv query $Drive } "Querying Dev Drive $Drive" Write-Output "Using Dev Drive at $Drive" } catch { - $Drive = Use-FallbackDrive "Failed to create Dev Drive: $($_.Exception.Message)" + throw "Failed to create Dev Drive: $($_.Exception.Message)" } } -$Tmp = "$Drive\codex-tmp" -New-Item -Path $Tmp -ItemType Directory -Force | Out-Null - -@( - "DEV_DRIVE=$Drive" - "TMP=$Tmp" - "TEMP=$Tmp" -) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append +"CI_BUILD_ROOT=$Drive" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py index f06e34b895..522c3a41f8 100644 --- a/.github/scripts/test_run_bazel_with_buildbuddy.py +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -214,6 +214,48 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase): ], ) + def test_bazel_command_uses_configured_local_caches(self) -> None: + env = { + "BAZEL_REPO_CONTENTS_CACHE": "/tmp/bazel-repo-contents", + "BAZEL_REPOSITORY_CACHE": "/tmp/bazel-repository", + } + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "build", + "--config=local", + "//codex-rs/...", + env=env, + ), + [ + "bazel", + "build", + "--config=local", + "//codex-rs/...", + "--repo_contents_cache=/tmp/bazel-repo-contents", + "--repository_cache=/tmp/bazel-repository", + ], + ) + + def test_bazel_command_adds_local_caches_before_separator(self) -> None: + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "build", + "//codex-rs/...", + "--", + "--program-arg", + env={"BAZEL_REPOSITORY_CACHE": "/tmp/bazel-repository"}, + ), + [ + "bazel", + "build", + "//codex-rs/...", + "--repository_cache=/tmp/bazel-repository", + "--", + "--program-arg", + ], + ) + def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: spaced_arg = ( r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin" diff --git a/.github/scripts/test_v8_canary_changes.py b/.github/scripts/test_v8_canary_changes.py index 0d8d9a2b32..a197a20af3 100644 --- a/.github/scripts/test_v8_canary_changes.py +++ b/.github/scripts/test_v8_canary_changes.py @@ -47,13 +47,17 @@ version = "149.2.0" ) ) - def test_setup_ci_change_requires_canary_and_source_build(self) -> None: - changed_files = {".github/actions/setup-ci/action.yml"} - - self.assertTrue(canary_required(changed_files, "149.2.0", "149.2.0")) - self.assertTrue( - windows_source_required(changed_files, "149.2.0", "149.2.0") - ) + def test_shared_ci_setup_changes_require_canary_and_source_build(self) -> None: + for path in ( + ".github/actions/setup-ci/action.yml", + ".github/scripts/setup-dev-drive.ps1", + ): + with self.subTest(path=path): + changed_files = {path} + self.assertTrue(canary_required(changed_files, "149.2.0", "149.2.0")) + self.assertTrue( + windows_source_required(changed_files, "149.2.0", "149.2.0") + ) def test_manual_dispatch_requires_source_build(self) -> None: self.assertTrue( diff --git a/.github/scripts/v8_canary_changes.py b/.github/scripts/v8_canary_changes.py index 53a064e384..958aa85432 100644 --- a/.github/scripts/v8_canary_changes.py +++ b/.github/scripts/v8_canary_changes.py @@ -25,6 +25,7 @@ CANARY_PATH_PATTERNS = { ".github/scripts/run_bazel_with_buildbuddy.py", ".github/scripts/rusty_v8_bazel.py", ".github/scripts/rusty_v8_module_bazel.py", + ".github/scripts/setup-dev-drive.ps1", ".github/scripts/v8_canary_changes.py", ".github/workflows/postmerge-ci.yml", ".github/workflows/rusty-v8-release.yml", @@ -44,6 +45,7 @@ WINDOWS_SOURCE_BUILD_PATHS = { ".github/actions/setup-ci/**", ".github/scripts/rusty_v8_bazel.py", ".github/scripts/rusty_v8_module_bazel.py", + ".github/scripts/setup-dev-drive.ps1", ".github/scripts/v8_canary_changes.py", ".github/workflows/rusty-v8-release.yml", ".github/workflows/v8-canary.yml", diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml index 5f22c74b73..09082506e4 100644 --- a/.github/workflows/rust-ci-full-nextest-platform.yml +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -71,11 +71,6 @@ jobs: with: persist-credentials: false - - name: Configure Dev Drive (Windows) - if: ${{ runner.os == 'Windows' }} - shell: pwsh - run: ../.github/scripts/setup-dev-drive.ps1 - - uses: ./.github/actions/setup-ci - name: Install Linux build dependencies @@ -136,11 +131,7 @@ jobs: echo "Using sccache GitHub backend" else echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" - if [[ -n "${DEV_DRIVE:-}" ]]; then - echo "SCCACHE_DIR=${DEV_DRIVE}\\.sccache" >> "$GITHUB_ENV" - else - echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" - fi + echo "SCCACHE_DIR=${CI_BUILD_ROOT}/sccache" >> "$GITHUB_ENV" echo "Using sccache local disk + actions/cache fallback" fi diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 41f8874712..0c6bb8993e 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -160,8 +160,7 @@ jobs: continue-on-error: true uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: - path: | - ~/.cache/bazel-repo-cache + path: ${{ steps.setup_bazel.outputs.repository-cache-path }} key: bazel-cache-x86_64-unknown-linux-gnu-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} - name: Check for a clean worktree diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index d5f0133528..97610cd318 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -308,8 +308,6 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read - env: - CARGO_TARGET_DIR: ${{ github.workspace }}/upstream-rusty-v8/target strategy: fail-fast: false matrix: @@ -322,7 +320,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: ./.github/actions/setup-ci + - id: setup_ci + uses: ./.github/actions/setup-ci - name: Configure git for upstream checkout shell: bash @@ -366,8 +365,8 @@ jobs: uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: | - ${{ env.CARGO_TARGET_DIR }}/sccache - ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release/gn_out + ${{ steps.setup_ci.outputs.cargo-target-dir }}/sccache + ${{ steps.setup_ci.outputs.cargo-target-dir }}/${{ matrix.target }}/release/gn_out key: rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }} restore-keys: | rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox- @@ -376,7 +375,7 @@ jobs: shell: pwsh env: SCCACHE_CACHE_SIZE: 256M - SCCACHE_DIR: ${{ env.CARGO_TARGET_DIR }}/sccache + SCCACHE_DIR: ${{ steps.setup_ci.outputs.cargo-target-dir }}/sccache SCCACHE_IDLE_TIMEOUT: 0 run: | $version = "v0.8.2" diff --git a/scripts/test-remote-env.sh b/scripts/test-remote-env.sh index aac888e7d4..7fcdd1ca37 100755 --- a/scripts/test-remote-env.sh +++ b/scripts/test-remote-env.sh @@ -25,7 +25,7 @@ setup_remote_env() { local remote_exec_server_stdout_path container_name="${CODEX_TEST_REMOTE_ENV_CONTAINER_NAME:-codex-remote-test-env-local-$(date +%s)-${RANDOM}}" - codex_binary_path="${REPO_ROOT}/codex-rs/target/debug/codex" + codex_binary_path="${CARGO_TARGET_DIR:-${REPO_ROOT}/codex-rs/target}/debug/codex" if ! command -v docker >/dev/null 2>&1; then echo "docker is required (Colima or Docker Desktop)" >&2 From 4b64bf0751a692d25691f361198c8d263e807839 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 8 Jul 2026 14:07:43 -0700 Subject: [PATCH 51/53] chore: remove inert cargo audit workflow (#31461) ## Why `codex-rs/.github/workflows/cargo-audit.yml` is nested below the repository root, so GitHub Actions never discovers or runs it. RustSec advisory enforcement already runs through the root `cargo-deny` workflow. ## What Remove the inert nested Cargo audit workflow. ## Validation - Ran `git diff --check`. - Verified the workflow is absent from GitHub registered workflows and that root blocking CI invokes `cargo-deny`. --- codex-rs/.github/workflows/cargo-audit.yml | 26 ---------------------- 1 file changed, 26 deletions(-) delete mode 100644 codex-rs/.github/workflows/cargo-audit.yml diff --git a/codex-rs/.github/workflows/cargo-audit.yml b/codex-rs/.github/workflows/cargo-audit.yml deleted file mode 100644 index 1d935d61e8..0000000000 --- a/codex-rs/.github/workflows/cargo-audit.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Cargo audit - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - audit: - runs-on: ubuntu-latest - defaults: - run: - working-directory: codex-rs - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - - name: Install cargo-audit - uses: taiki-e/install-action@v2 - with: - tool: cargo-audit - - name: Run cargo audit - run: cargo audit --deny warnings From 5c7624a69e5620d749e0afa99a40e60e02f7ddc9 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 8 Jul 2026 14:13:59 -0700 Subject: [PATCH 52/53] test: migrate app-server v2 starts to auto env (#31614) ## Why We should be running as many integrations tests as possible against the split cross-OS configuration. ## What - migrate eligible thread starts and builders to auto env - keep explicit custom/local-executor cases local with rationale comments - keep auto-env coverage where possible and add narrow `TODO(anp)` skips for fixtures that are not target-native yet --- codex-rs/app-server/tests/suite/v2/account.rs | 12 +-- .../app-server/tests/suite/v2/attestation.rs | 3 +- .../app-server/tests/suite/v2/compaction.rs | 7 +- .../app-server/tests/suite/v2/executor_mcp.rs | 1 + .../tests/suite/v2/executor_skills.rs | 10 +- .../tests/suite/v2/experimental_api.rs | 3 - .../suite/v2/experimental_feature_list.rs | 3 +- .../app-server/tests/suite/v2/hooks_list.rs | 15 +-- .../tests/suite/v2/imagegen_extension.rs | 13 +-- .../app-server/tests/suite/v2/mcp_resource.rs | 59 ++++++++--- .../tests/suite/v2/mcp_server_status.rs | 3 +- .../app-server/tests/suite/v2/mcp_tool.rs | 1 + .../tests/suite/v2/realtime_conversation.rs | 10 +- .../tests/suite/v2/recommended_plugins.rs | 3 +- .../suite/v2/selected_capability_stack.rs | 3 + .../app-server/tests/suite/v2/skills_list.rs | 11 +- .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../app-server/tests/suite/v2/thread_list.rs | 3 +- .../tests/suite/v2/thread_resume.rs | 91 +++++++--------- .../tests/suite/v2/thread_settings_update.rs | 8 +- .../tests/suite/v2/thread_shell_command.rs | 3 + .../app-server/tests/suite/v2/thread_start.rs | 44 ++++---- .../tests/suite/v2/thread_status.rs | 3 +- .../tests/suite/v2/thread_unsubscribe.rs | 67 +++++++----- .../tests/suite/v2/turn_interrupt.rs | 19 +++- .../app-server/tests/suite/v2/turn_start.rs | 100 ++++++++++++------ .../tests/suite/v2/turn_start_zsh_fork.rs | 30 +++++- .../app-server/tests/suite/v2/turn_steer.rs | 31 ++++-- .../app-server/tests/suite/v2/web_search.rs | 4 +- 29 files changed, 327 insertions(+), 237 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index ccf46321e6..a8e8c0903d 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -511,7 +511,6 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -538,7 +537,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { .await??; let thread_req = mcp - .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + .send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -623,7 +622,6 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -650,7 +648,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { .await??; let thread_req = mcp - .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + .send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -751,7 +749,6 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -778,7 +775,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { .await??; let thread_req = mcp - .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + .send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -872,7 +869,6 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -899,7 +895,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { .await??; let thread_req = mcp - .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + .send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 4499c7a6d2..9df1571627 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -69,7 +69,6 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -95,7 +94,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand }; let thread_request_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let thread_response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 7f241d28d3..a534912a89 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -83,7 +83,6 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -170,7 +169,6 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -281,7 +279,6 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -335,7 +332,6 @@ async fn thread_compact_start_rejects_invalid_thread_id() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -375,7 +371,6 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -399,7 +394,7 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { async fn start_thread(mcp: &mut TestAppServer) -> Result { let thread_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index b240308a08..4062ac9c0b 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -178,6 +178,7 @@ HTTP_PROXY = {http_proxy} let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) + // This suite owns environments.toml to exercise explicit executor selection. .without_auto_env() .build() .await?; diff --git a/codex-rs/app-server/tests/suite/v2/executor_skills.rs b/codex-rs/app-server/tests/suite/v2/executor_skills.rs index 42120ccb39..3fe9e5b5d4 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_skills.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_skills.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; use codex_utils_path_uri::PathUri; use core_test_support::responses; +use core_test_support::skip_if_remote; use tempfile::TempDir; use tokio::time::timeout; @@ -23,6 +24,12 @@ const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER"; #[tokio::test] async fn selected_executor_root_exposes_plugin_skill() -> Result<()> { + // TODO(anp): Remove after selected capability-root fixtures can be materialized in remote exec. + skip_if_remote!( + Ok(()), + "selected capability root fixture is only materialized on the host" + ); + let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_once( &server, @@ -83,13 +90,12 @@ stream_max_retries = 0 let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), selected_capability_roots: Some(vec![SelectedCapabilityRoot { id: "demo-plugin@1".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 8a33a33d2c..cc010b93eb 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -253,7 +253,6 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; let init = mcp @@ -296,7 +295,6 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; let init = mcp @@ -338,7 +336,6 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; let init = mcp diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 6b7f3aea2c..8d66855d52 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -206,14 +206,13 @@ memories = true let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 71cc2dbaa8..35b19addd8 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -26,6 +26,7 @@ use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::skip_if_host_windows; +use core_test_support::skip_if_remote; use pretty_assertions::assert_eq; use serde::Serialize; use tempfile::TempDir; @@ -137,7 +138,6 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -309,7 +309,6 @@ async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -328,7 +327,7 @@ async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> { std::fs::remove_file(plugin_mcp_path)?; let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let _: ThreadStartResponse = to_response( timeout( @@ -680,6 +679,8 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { #[tokio::test] async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> { skip_if_host_windows!(Ok(())); + // TODO(anp): Teach command-hook fixtures to run in selected remote environments. + skip_if_remote!(Ok(()), "command hooks use host-local script and log paths"); let responses = vec![ create_final_assistant_message_sse_response("Warmup")?, @@ -737,7 +738,6 @@ command = "python3 {hook_script_path}" let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -757,7 +757,7 @@ command = "python3 {hook_script_path}" assert_eq!(hook.trust_status, HookTrustStatus::Untrusted); let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -935,6 +935,8 @@ command = "python3 {hook_script_path}" #[tokio::test] async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> { skip_if_host_windows!(Ok(())); + // TODO(anp): Teach command-hook fixtures to run in selected remote environments. + skip_if_remote!(Ok(()), "command hooks use host-local script and log paths"); let responses = vec![ create_final_assistant_message_sse_response("Warmup")?, @@ -991,7 +993,6 @@ command = "python3 {hook_script_path}" let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -1034,7 +1035,7 @@ command = "python3 {hook_script_path}" let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 0c3799ca11..d62125010f 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; +use core_test_support::skip_if_remote; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -90,7 +91,6 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -192,7 +192,6 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -236,6 +235,11 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> #[tokio::test] async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()> { + skip_if_remote!( + Ok(()), + "remote executors use different imagegen storage approaches, so host-local image paths are unavailable" + ); + let edit_request = run_image_edit_test(|codex_home| { let image_path = codex_home.join("attached.png"); std::fs::write(&image_path, TINY_PNG_BYTES)?; @@ -317,7 +321,6 @@ async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<() let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -384,7 +387,6 @@ generatedImage(result); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -468,7 +470,6 @@ async fn run_image_edit_test( let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; @@ -499,7 +500,7 @@ async fn run_image_edit_test( async fn start_turn(mcp: &mut TestAppServer, input: Vec) -> Result<()> { let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index d973835a16..cd4efee8ab 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -89,11 +89,15 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> { let (apps_server_url, _apps_server_calls, apps_server_handle) = start_resource_apps_mcp_server().await?; let responses_server_uri = responses_server.uri(); - let (_codex_home, mut mcp) = - start_resource_test_app_server(&apps_server_url, &responses_server_uri).await?; + let (_codex_home, mut mcp) = start_resource_test_app_server( + &apps_server_url, + &responses_server_uri, + ResourceTestEnvironment::Auto, + ) + .await?; let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -133,8 +137,12 @@ async fn orchestrator_skill_can_read_referenced_resource_without_an_executor() - let (apps_server_url, apps_server_calls, apps_server_handle) = start_resource_apps_mcp_server().await?; let responses_server_uri = responses_server.uri(); - let (_codex_home, mut mcp) = - start_resource_test_app_server(&apps_server_url, &responses_server_uri).await?; + let (_codex_home, mut mcp) = start_resource_test_app_server( + &apps_server_url, + &responses_server_uri, + ResourceTestEnvironment::Auto, + ) + .await?; let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { @@ -369,8 +377,13 @@ async fn local_executor_does_not_expose_orchestrator_skills() -> Result<()> { let (apps_server_url, _apps_server_calls, apps_server_handle) = start_resource_apps_mcp_server().await?; let responses_server_uri = responses_server.uri(); - let (_codex_home, mut mcp) = - start_resource_test_app_server(&apps_server_url, &responses_server_uri).await?; + let (_codex_home, mut mcp) = start_resource_test_app_server( + &apps_server_url, + &responses_server_uri, + // This test exercises the implicit local executor. + ResourceTestEnvironment::Local, + ) + .await?; let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { @@ -449,13 +462,13 @@ async fn disabled_orchestrator_skills_do_not_expose_skills_namespace() -> Result [orchestrator.skills] enabled = false "#, + ResourceTestEnvironment::Auto, ) .await?; let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), - environments: Some(Vec::new()), ..Default::default() }) .await?; @@ -648,15 +661,22 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { async fn start_resource_test_app_server( apps_server_url: &str, responses_server_uri: &str, + environment: ResourceTestEnvironment, ) -> Result<(TempDir, TestAppServer)> { - start_resource_test_app_server_with_extra_config(apps_server_url, responses_server_uri, "") - .await + start_resource_test_app_server_with_extra_config( + apps_server_url, + responses_server_uri, + "", + environment, + ) + .await } async fn start_resource_test_app_server_with_extra_config( apps_server_url: &str, responses_server_uri: &str, extra_config: &str, + environment: ResourceTestEnvironment, ) -> Result<(TempDir, TestAppServer)> { let codex_home = TempDir::new()?; std::fs::write( @@ -696,15 +716,22 @@ stream_max_retries = 0 AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::builder() - .with_codex_home(codex_home.path()) - .without_auto_env() - .build() - .await?; + let builder = TestAppServer::builder().with_codex_home(codex_home.path()); + let builder = match environment { + ResourceTestEnvironment::Auto => builder, + // The Local caller explicitly exercises the implicit local executor. + ResourceTestEnvironment::Local => builder.without_auto_env(), + }; + let mut mcp = builder.build().await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok((codex_home, mcp)) } +enum ResourceTestEnvironment { + Auto, + Local, +} + async fn start_resource_apps_mcp_server() -> Result<(String, Arc, JoinHandle<()>)> { let listener = TcpListener::bind("127.0.0.1:0").await?; diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 1036847e43..a609eb3257 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -138,13 +138,12 @@ async fn mcp_server_status_list_uses_thread_project_local_config() -> Result<()> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index cf15e7031a..ff8d0fcacd 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -348,6 +348,7 @@ url = "{mcp_server_url}/mcp" let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) + // This test adds and refreshes an explicitly selected runtime environment. .without_auto_env() .build() .await?; diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index f85eb526f7..185631ea2f 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -56,6 +56,7 @@ use core_test_support::responses::WebSocketTestServer; use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_remote; use pretty_assertions::assert_eq; use serde::de::DeserializeOwned; use serde_json::Value; @@ -293,14 +294,13 @@ impl RealtimeE2eHarness { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let thread_start_response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -1173,7 +1173,6 @@ async fn realtime_list_voices_returns_supported_names() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -2520,6 +2519,11 @@ async fn websocket_v2_background_agent_progress_is_sent_before_function_output() #[tokio::test] async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<()> { + // TODO(anp): Remove after delegated shell commands resolve target-native cwd in remote environments. + skip_if_remote!( + Ok(()), + "delegated shell command cwd is only materialized on the host" + ); skip_if_no_network!(Ok(())); // Phase 1: keep the two mocked OpenAI conversations explicit. The realtime sideband only diff --git a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs index 609dc28a3d..71c2b12a8d 100644 --- a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs +++ b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs @@ -74,7 +74,6 @@ async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Resu let sqlite_home = codex_home.path().to_string_lossy(); let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .with_env_overrides(&[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))]) .build() @@ -105,7 +104,7 @@ async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Resu ); let thread_id = app_server - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index 62817677ac..9e8983840b 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -142,6 +142,7 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() let mut app_server = TestAppServer::builder() .with_codex_home(fixture.codex_home.path()) + // This fixture owns environments.toml and selects its environments explicitly. .without_auto_env() .build() .await?; @@ -192,6 +193,7 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() let mut app_server = TestAppServer::builder() .with_codex_home(fixture.codex_home.path()) + // This fixture owns environments.toml and selects its environments explicitly. .without_auto_env() .build() .await?; @@ -343,6 +345,7 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> let mut app_server = TestAppServer::builder() .with_codex_home(fixture.codex_home.path()) + // This fixture owns environments.toml and selects its environments explicitly. .without_auto_env() .build() .await?; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index e64eda0f22..c17a54b844 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -24,6 +24,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::skip_if_remote; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -874,6 +875,12 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { #[tokio::test] async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { + // TODO(anp): Remove after skill watching can bridge host-local storage into remote exec. + skip_if_remote!( + Ok(()), + "host-local skill changes are not visible to remote executors" + ); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; write_mock_responses_config_toml_with_chatgpt_base_url( @@ -885,8 +892,6 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() - .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -911,7 +916,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( ); let thread_start_request_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: None, model_provider: None, allow_provider_model_fallback: false, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 0a2d1ff453..a33031517b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -120,7 +120,6 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -263,13 +262,12 @@ async fn thread_fork_at_last_turn_id_keeps_only_terminal_prefix() -> Result<()> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let start_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 26cf6c56ad..8176118ecf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -52,7 +52,6 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs async fn init_mcp(codex_home: &Path) -> Result { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,7 +260,7 @@ async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Resul let mut mcp = init_mcp(codex_home.path()).await?; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index a9c3a1f700..2d44bb21e8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -85,6 +85,8 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use core_test_support::responses; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_remote; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde_json::json; use std::fs::FileTimes; @@ -269,6 +271,11 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { #[tokio::test] async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> { + skip_if_remote!( + Ok(()), + "cached instruction-source fixture is outside the selected remote cwd" + ); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -278,6 +285,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) + // TODO(anp): Move the cached instruction-source fixture into the auto environment cwd. .without_auto_env() .build() .await?; @@ -360,13 +368,12 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -443,14 +450,13 @@ async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ephemeral: Some(true), ..Default::default() @@ -507,7 +513,6 @@ async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> .expect("test codex home should be utf-8"); let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home_path) - .without_auto_env() .without_managed_config() .with_env_overrides(&[("CODEX_SQLITE_HOME", Some(sqlite_home))]) .build() @@ -515,7 +520,7 @@ async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ..Default::default() }) @@ -605,7 +610,6 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; @@ -654,14 +658,13 @@ async fn thread_resume_running_thread_tracks_thread_originator_in_analytics() -> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), thread_source: Some(ThreadSource::User), service_name: Some("codex_work_desktop".to_string()), @@ -778,7 +781,6 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -977,7 +979,6 @@ async fn resume_redaction_fixture(client_name: Option<&str>) -> Result Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -1142,7 +1142,6 @@ async fn thread_resume_rejects_archived_session_by_id() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -1185,14 +1184,13 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ..Default::default() }) @@ -1295,14 +1293,13 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ..Default::default() }) @@ -1399,14 +1396,13 @@ async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ..Default::default() }) @@ -1499,7 +1495,6 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; @@ -1616,14 +1611,13 @@ async fn thread_goal_lifecycle_emits_analytics_and_clear_deletes_goal() -> Resul let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT.saturating_mul(2), mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ..Default::default() }) @@ -1806,7 +1800,6 @@ async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<( let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -1863,7 +1856,6 @@ async fn thread_resume_skips_restored_token_usage_when_turns_are_excluded() -> R let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -1975,7 +1967,6 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -2103,7 +2094,6 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -2301,7 +2291,6 @@ stream_max_retries = 0 let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -2399,7 +2388,6 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -2477,7 +2465,6 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -2603,13 +2590,12 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let mut primary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -2646,7 +2632,6 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let mut secondary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; @@ -2720,13 +2705,12 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let mut primary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -2843,13 +2827,12 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result let mut primary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -3016,13 +2999,12 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let mut primary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -3151,13 +3133,12 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let mut primary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -3193,7 +3174,6 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let mut secondary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; @@ -3223,6 +3203,12 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { #[tokio::test] async fn thread_resume_replays_pending_command_execution_request_approval() -> Result<()> { + // TODO(anp): Remove after shell approval replay can route target-native cwd across host OSes. + skip_if_wine_exec!( + Ok(()), + "shell approval replay rejects the Windows cwd on the Linux host" + ); + let responses = vec![ create_final_assistant_message_sse_response("seeded")?, create_shell_command_sse_response( @@ -3243,13 +3229,12 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let mut primary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -3365,6 +3350,12 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R #[tokio::test] async fn thread_resume_replays_pending_file_change_request_approval() -> Result<()> { + // TODO(anp): Remove after apply-patch approval fixtures use a target-native workspace. + skip_if_remote!( + Ok(()), + "apply-patch approval fixture is only materialized on the host" + ); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -3386,13 +3377,12 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let mut primary = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -3611,7 +3601,6 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -3694,7 +3683,6 @@ async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> let refresh_token_url = format!("{}/oauth/token", server.uri()); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( @@ -3789,7 +3777,6 @@ async fn thread_resume_can_load_source_by_external_path() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -3883,13 +3870,12 @@ async fn start_materialized_thread_and_restart( ) -> Result { let mut first_mcp = TestAppServer::builder() .with_codex_home(codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; let start_id = first_mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -3946,7 +3932,6 @@ async fn start_materialized_thread_and_restart( let mut second_mcp = TestAppServer::builder() .with_codex_home(codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; @@ -3981,13 +3966,12 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let mut primary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.3-codex".to_string()), ..Default::default() }) @@ -4023,7 +4007,6 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let mut secondary = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index bd7b7b366c..bf3d6261b8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -46,7 +46,6 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -113,7 +112,6 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -167,7 +165,6 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -214,7 +211,6 @@ async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -284,7 +280,6 @@ async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Res let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -322,7 +317,6 @@ async fn turn_start_settings_override_emits_thread_settings_updated() -> Result< let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -402,7 +396,7 @@ async fn start_text_turn(mcp: &mut TestAppServer, thread_id: String) -> Result<( async fn start_thread(mcp: &mut TestAppServer) -> Result { let request_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index 03184ab80b..88628f1244 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -62,6 +62,7 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.as_path()) + // thread/shellCommand intentionally executes on the app-server host. .without_auto_env() .build() .await?; @@ -199,6 +200,7 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled() let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.as_path()) + // This test intentionally exercises thread/shellCommand without a local host environment. .without_auto_env() .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) .build() @@ -259,6 +261,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.as_path()) + // thread/shellCommand intentionally joins the app-server's host-local active turn. .without_auto_env() .build() .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index c98adb40f6..363b1c3389 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -525,13 +525,12 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(cwd.to_string_lossy().to_string()), runtime_workspace_roots: Some(vec![extra_root.abs()]), ..Default::default() @@ -570,13 +569,12 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(cwd.path().to_string_lossy().to_string()), ..Default::default() }) @@ -609,7 +607,6 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -648,15 +645,15 @@ async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> R let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let environment_id = mcp.auto_env_params()?.environment_id; let request_id = mcp .send_thread_start_request(ThreadStartParams { environments: Some(vec![TurnEnvironmentParams { - environment_id: "local".to_string(), + environment_id: environment_id.clone(), cwd: serde_json::from_value(json!("relative"))?, }]), ..Default::default() @@ -672,7 +669,9 @@ async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> R assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); assert_eq!( error.error.message, - "invalid cwd for environment `local`: path `relative` does not use absolute POSIX or Windows path syntax" + format!( + "invalid cwd for environment `{environment_id}`: path `relative` does not use absolute POSIX or Windows path syntax" + ) ); Ok(()) @@ -691,6 +690,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) + // TODO(anp): Move the instruction-source fixture into the auto environment cwd. .without_auto_env() .build() .await?; @@ -742,6 +742,7 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) + // TODO(anp): Move the instruction-source fixture into the auto environment cwd. .without_auto_env() .build() .await?; @@ -789,7 +790,6 @@ async fn thread_start_without_selected_environment_includes_only_global_instruct let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -883,14 +883,13 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { thread_source: Some(ThreadSource::User), service_name: Some("codex_work_desktop".to_string()), ..Default::default() @@ -938,13 +937,12 @@ model_reasoning_effort = "high" let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }) @@ -1294,7 +1292,6 @@ async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let refresh_token_url = format!("{}/oauth/token", server.uri()); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[ ("OPENAI_API_KEY", None), ( @@ -1307,7 +1304,7 @@ async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let err: JSONRPCError = timeout( @@ -1355,13 +1352,12 @@ model_reasoning_effort = "high" let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let first_request = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() @@ -1374,7 +1370,7 @@ model_reasoning_effort = "high" .await??; let second_request = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), ..Default::default() }) @@ -1419,13 +1415,12 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(nested.display().to_string()), sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() @@ -1461,13 +1456,12 @@ async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), ..Default::default() }) @@ -1503,13 +1497,12 @@ async fn thread_start_preserves_untrusted_project_trust() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() @@ -1548,13 +1541,12 @@ model_reasoning_effort = "high" let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index 957eb2ce14..dad1db88af 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -30,14 +30,13 @@ async fn thread_status_changed_emits_runtime_updates() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("RUST_LOG", Some("info"))]) .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index a1d5c8b4d7..2dc12319ec 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -44,12 +44,23 @@ async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<( let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let thread_id = start_thread(&mut mcp).await?; + let thread_req = mcp + .send_thread_start_request_with_auto_env(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id; let unsubscribe_id = mcp .send_thread_unsubscribe_request(ThreadUnsubscribeParams { @@ -99,8 +110,6 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; - let working_directory = tmp.path().join("workdir"); - std::fs::create_dir(&working_directory)?; let (server, mut completions) = start_streaming_sse_server(vec![ vec![StreamingSseChunk { @@ -127,13 +136,12 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), dynamic_tools: Some(vec![DynamicToolSpec::Function(DynamicToolFunctionSpec { name: tool_name.to_string(), @@ -164,7 +172,6 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { text: "run deterministic tool".to_string(), text_elements: Vec::new(), }], - cwd: Some(working_directory), ..Default::default() }) .await?; @@ -263,12 +270,23 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let thread_id = start_thread(&mut mcp).await?; + let thread_req = mcp + .send_thread_start_request_with_auto_env(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id; let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -354,12 +372,23 @@ async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Resul let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let thread_id = start_thread(&mut mcp).await?; + let thread_req = mcp + .send_thread_start_request_with_auto_env(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id; let first_unsubscribe_id = mcp .send_thread_unsubscribe_request(ThreadUnsubscribeParams { @@ -434,19 +463,3 @@ stream_max_retries = 0 ), ) } - -async fn start_thread(mcp: &mut TestAppServer) -> Result { - let req_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(req_id)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(resp)?; - Ok(thread.id) -} diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 28e9294225..2d47dd8ceb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::skip_if_remote; use tempfile::TempDir; use tokio::time::timeout; @@ -30,6 +31,12 @@ const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn turn_interrupt_aborts_running_turn() -> Result<()> { + // TODO(anp): Remove after the long-running command fixture can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "uses a host-local command and cwd fixture unavailable to remote executors" + ); + // Use a portable sleep command to keep the turn running. #[cfg(target_os = "windows")] let shell_command = vec![ @@ -59,14 +66,13 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a v2 thread and capture its id. let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -215,6 +221,12 @@ async fn turn_interrupt_rejects_completed_turn() -> Result<()> { #[tokio::test] async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> { + // TODO(anp): Remove after the approval command fixture can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "uses a host-local command and cwd fixture unavailable to remote executors" + ); + #[cfg(target_os = "windows")] let shell_command = vec![ "powershell".to_string(), @@ -245,13 +257,12 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 62b13a1e52..0f7cac5e3b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -80,6 +80,8 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_remote; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -618,7 +620,6 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( let isolated_home = codex_home.path().to_string_lossy(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[ ("HOME", Some(isolated_home.as_ref())), ("USERPROFILE", Some(isolated_home.as_ref())), @@ -628,7 +629,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -891,14 +892,13 @@ async fn turn_start_tracks_thread_originator_in_analytics() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), thread_source: Some(ThreadSource::User), service_name: Some("codex_work_desktop".to_string()), @@ -1021,14 +1021,13 @@ async fn turn_profile_tracks_blocking_tool_and_follow_up_sampling() -> Result<() let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -1348,13 +1347,12 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -2214,6 +2212,11 @@ async fn turn_start_forwards_custom_local_image_detail() -> Result<()> { #[tokio::test] async fn turn_start_exec_approval_toggle_v2() -> Result<()> { + // TODO(anp): Remove after shell-command approval routing supports target-native Windows cwd. + skip_if_wine_exec!( + Ok(()), + "shell-command approval routing requires a host-native cwd under Wine-exec" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -2256,14 +2259,14 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.as_path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let expected_environment_id = mcp.auto_env_params()?.environment_id; // thread/start let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -2304,7 +2307,10 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { panic!("expected CommandExecutionRequestApproval request"); }; assert_eq!(params.item_id, "call1"); - assert_eq!(params.environment_id.as_deref(), Some("local")); + assert_eq!( + params.environment_id.as_deref(), + Some(expected_environment_id.as_str()) + ); let resolved_request_id = request_id.clone(); // Approve and wait for task completion @@ -2376,12 +2382,15 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { #[tokio::test] async fn turn_start_exec_approval_decline_v2() -> Result<()> { + // TODO(anp): Remove after command approval routing accepts target-native Windows cwd. + skip_if_wine_exec!( + Ok(()), + "command approval routing rejects the selected Windows cwd on the Linux host" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; let codex_home = tmp.path().to_path_buf(); - let workspace = tmp.path().join("workspace"); - std::fs::create_dir(&workspace)?; let responses = vec![ create_shell_command_sse_response( @@ -2406,13 +2415,12 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.as_path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -2432,7 +2440,6 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { text: "run python".to_string(), text_elements: Vec::new(), }], - cwd: Some(workspace.clone()), ..Default::default() }) .await?; @@ -2525,6 +2532,8 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { #[tokio::test] async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { + // TODO(anp): Materialize cwd and shell-display fixtures in the selected remote environment. + skip_if_remote!(Ok(()), "cwd fixtures are only materialized on the host"); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -2563,14 +2572,13 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // thread/start let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -2761,13 +2769,12 @@ stream_max_retries = 0 let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -2876,6 +2883,8 @@ url = "ws://127.0.0.1:1" let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) + // This test owns environments.toml and explicitly compares local selections + // with a configured remote environment, so auto env would change its subject. .without_auto_env() .build() .await?; @@ -3008,6 +3017,11 @@ fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option Result<()> { + // TODO(anp): Materialize apply-patch workspaces in the selected remote environment. + skip_if_remote!( + Ok(()), + "apply-patch workspace fixture is only materialized on the host" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -3035,13 +3049,12 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -3181,6 +3194,11 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { #[tokio::test] async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v2() -> Result<()> { + // TODO(anp): Materialize apply-patch workspaces in the selected remote environment. + skip_if_remote!( + Ok(()), + "apply-patch workspace fixture is only materialized on the host" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -3230,13 +3248,12 @@ async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -3283,6 +3300,11 @@ async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v #[tokio::test] async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { + // TODO(anp): Materialize apply-patch workspaces in the selected remote environment. + skip_if_remote!( + Ok(()), + "apply-patch workspace fixture is only materialized on the host" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -3372,13 +3394,12 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -4020,6 +4041,11 @@ config_file = "./custom-role.toml" #[tokio::test] async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Result<()> { + // TODO(anp): Materialize apply-patch workspaces in the selected remote environment. + skip_if_remote!( + Ok(()), + "apply-patch workspace fixture is only materialized on the host" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -4057,13 +4083,12 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -4207,6 +4232,11 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res #[tokio::test] async fn turn_start_file_change_approval_decline_v2() -> Result<()> { + // TODO(anp): Materialize apply-patch workspaces in the selected remote environment. + skip_if_remote!( + Ok(()), + "apply-patch workspace fixture is only materialized on the host" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -4234,13 +4264,12 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -4367,6 +4396,11 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { #[tokio::test] #[cfg_attr(windows, ignore = "process id reporting differs on Windows")] async fn command_execution_notifications_include_process_id() -> Result<()> { + // TODO(anp): Add target-Windows process-id expectations for remote executors. + skip_if_wine_exec!( + Ok(()), + "process id reporting differs for a Windows executor" + ); skip_if_no_network!(Ok(())); let responses = vec![ @@ -4385,13 +4419,12 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -4523,13 +4556,12 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_request = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 533f925798..7a45fabf57 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -34,6 +34,7 @@ use codex_features::FEATURES; use codex_features::Feature; use core_test_support::responses; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_remote; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::path::Path; @@ -48,6 +49,11 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[tokio::test] async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { + // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "zsh-fork fixtures use host-local zsh and workspace paths" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -101,7 +107,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -177,6 +183,11 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { #[tokio::test] async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { + // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "zsh-fork fixtures use host-local zsh and workspace paths" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -220,7 +231,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -313,6 +324,11 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { #[tokio::test] async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { + // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "zsh-fork fixtures use host-local zsh and workspace paths" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -353,7 +369,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -444,6 +460,11 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { #[tokio::test] async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() -> Result<()> { + // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "zsh-fork fixtures use host-local zsh and workspace paths" + ); skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -512,7 +533,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -748,7 +769,6 @@ async fn create_zsh_test_mcp_process( let zdotdir = zdotdir.to_string_lossy().into_owned(); TestAppServer::builder() .with_codex_home(codex_home) - .without_auto_env() .with_program(&app_server) .with_env_overrides(&[("ZDOTDIR", Some(zdotdir.as_str()))]) .build() diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 10ad608ec3..5fb44fb92b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use core_test_support::skip_if_remote; use serde_json::Value; use std::collections::HashMap; use tempfile::TempDir; @@ -52,14 +53,13 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -111,6 +111,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { #[tokio::test] async fn turn_steer_rejects_oversized_text_input() -> Result<()> { + // TODO(anp): Remove after the active-turn fixture can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "uses a host-local command and cwd fixture unavailable to remote executors" + ); + #[cfg(target_os = "windows")] let shell_command = vec![ "powershell".to_string(), @@ -143,14 +149,13 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -228,6 +233,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { #[tokio::test] async fn turn_steer_returns_active_turn_id() -> Result<()> { + // TODO(anp): Remove after the active-turn fixture can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "uses a host-local command and cwd fixture unavailable to remote executors" + ); + #[cfg(target_os = "windows")] let shell_command = vec![ "powershell".to_string(), @@ -262,14 +273,13 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) @@ -379,6 +389,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { #[tokio::test] async fn turn_steer_rejects_context_only_input_without_merging_context() -> Result<()> { + // TODO(anp): Remove after the active-turn fixture can run in the selected remote environment. + skip_if_remote!( + Ok(()), + "uses a host-local command and cwd fixture unavailable to remote executors" + ); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -404,14 +420,13 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) - .without_auto_env() .without_managed_config() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 01477466e4..1e32ae87e6 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -81,14 +81,13 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -203,7 +202,6 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { drop(mcp); let mut reloaded_mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .with_env_overrides(&[("OPENAI_API_KEY", None)]) .build() .await?; From b6f9aee16dc7dc10fbf7fc17c6757c4183dee058 Mon Sep 17 00:00:00 2001 From: fbauer33 Date: Wed, 8 Jul 2026 15:14:23 -0700 Subject: [PATCH 53/53] [codex] increase tool schema compaction threshold (#31497) ## Why The 4,000-byte limit is compacting the tool schemas of some hero usecases. ## What changed Raise the limit to 5,000 bytes and update compaction test fixtures accordingly. --- codex-rs/tools/src/json_schema.rs | 2 +- codex-rs/tools/src/json_schema_tests.rs | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index acffb78b11..7c9edd08e1 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -219,7 +219,7 @@ fn deserialize_tool_input_schema(input_schema: JsonValue) -> Result serde_json::Map