From e48ab86693c56ae500ff1e3ca08ec71272628170 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 22 Jun 2026 12:31:52 -0700 Subject: [PATCH 01/12] core: remove unused permissions cwd plumbing (#29468) ## Why `compile_scoped_filesystem_pattern()` accepted a `_policy_cwd` parameter even though scoped glob compilation no longer uses the policy working directory. Keeping that unused argument forced the surrounding permissions compilation path to keep forwarding `policy_cwd` through call sites that did not need it, making the API look more dependent on cwd resolution than it is. ## What changed Removed the unused cwd parameter from `compile_scoped_filesystem_pattern()` and the callers that only forwarded it: `compile_filesystem_permission()`, `compile_permission_profile()`, and `compile_permission_profile_selection()`. Workspace root resolution still keeps `policy_cwd`, because that path still resolves relative roots against the active policy cwd. Relevant code: [`codex-rs/core/src/config/permissions.rs`](https://github.com/openai/codex/blob/b8b9816102e064dae4488ec130cf560f63c1ab78/codex-rs/core/src/config/permissions.rs#L346). ## Verification - `just test -p codex-core config::permissions` - `just test -p codex-core` was also run after building `test_stdio_server`; it passed the touched permissions coverage but still reported unrelated existing failures in `cli_stream` and shell snapshot tests. --- codex-rs/core/src/config/mod.rs | 1 - codex-rs/core/src/config/permissions.rs | 11 ++--------- codex-rs/core/src/config/permissions_tests.rs | 2 -- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dba933b11184..17161e1a3c4c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3207,7 +3207,6 @@ impl Config { effective_permission_selection.profiles.as_ref(), default_permissions, builtin_workspace_write_settings, - resolved_cwd.as_path(), &mut startup_warnings, )?; let mut configured_workspace_roots = compile_permission_profile_workspace_roots( diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index f683d9c7eb38..9c6d3ed72519 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -346,7 +346,6 @@ pub(crate) fn network_proxy_config_for_profile_selection( pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { let profile = resolve_permission_profile(permissions, profile_name)?; @@ -383,7 +382,6 @@ pub(crate) fn compile_permission_profile( .extend(compile_filesystem_permission( path, permission, - policy_cwd, startup_warnings, )?); } @@ -412,7 +410,6 @@ pub(crate) fn compile_permission_profile_selection( permissions: Option<&PermissionsToml>, profile_name: &str, workspace_write: Option<&SandboxWorkspaceWrite>, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { if let Some(permission_profile) = builtin_permission_profile(profile_name, workspace_write) { @@ -426,7 +423,7 @@ pub(crate) fn compile_permission_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) + compile_permission_profile(permissions, profile_name, startup_warnings) } pub(crate) fn compile_permission_profile_workspace_roots( @@ -524,7 +521,6 @@ fn compile_network_sandbox_policy( fn compile_filesystem_permission( path: &str, permission: &FilesystemPermissionToml, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result> { let mut entries = Vec::new(); @@ -548,9 +544,7 @@ fn compile_filesystem_permission( // exact-path parser so existing path semantics stay intact. let entry = FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { - pattern: compile_scoped_filesystem_pattern( - path, subpath, *access, policy_cwd, - )?, + pattern: compile_scoped_filesystem_pattern(path, subpath, *access)?, }, access: *access, }; @@ -643,7 +637,6 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 88a757181e71..2c1f649042c2 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -543,7 +543,6 @@ fn glob_scan_max_depth_must_be_positive() { #[test] fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> { - let cwd = TempDir::new()?; let mut startup_warnings = Vec::new(); let (file_system_policy, _) = compile_permission_profile( &PermissionsToml { @@ -568,7 +567,6 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> )]), }, "workspace", - cwd.path(), &mut startup_warnings, )?; From 1659c4a6291ae956f020c9c7354ff3cc3d050c48 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 13:03:11 -0700 Subject: [PATCH 02/12] PAC 2 - Add shared auth system proxy contract (#26707) ## Summary Stacked on #26706. Adds the shared auth/system-proxy contract that later platform resolver PRs plug into. This PR moves Codex-owned auth and startup HTTP clients through a common route-aware boundary, but does not yet add Windows or macOS system proxy resolution. The default path remains unchanged when `respect_system_proxy` is absent or disabled. ## Implementation - Adds `codex-client/src/outbound_proxy.rs` with the shared route-selection model: - `OutboundProxyConfig`; - `ClientRouteClass`; - `RouteFailureClass`; - `build_reqwest_client_for_route`. - Preserves the existing reqwest/default-client behavior when no route config is supplied. - Uses the fixed MVP routing policy when route config is supplied: platform system/PAC/WPAD discovery, then explicit env proxy variables, then direct connection. - Keeps platform-specific system discovery behind the shared client boundary. This PR provides the contract and fallback behavior; later resolver PRs plug in Windows and macOS discovery. - Adds `login::AuthRouteConfig` so auth call sites depend on a small policy type instead of platform resolver details. - Maps the resolved `Config.respect_system_proxy` boolean into `AuthRouteConfig` for auth-owned clients. - Wires the route config through browser login, device-code login, access-token login, login status, logout/revoke, token refresh, API-key exchange, app-server account login, TUI/app startup, cloud-config bootstrap, cloud tasks, plugin auth, and exec startup config loading. ## End-user behavior - No behavior changes by default. - When `respect_system_proxy = true`, auth-owned clients opt into the shared route-aware client path. - On platforms without a resolver implementation in this PR, system discovery is unavailable and the route-aware path falls back to explicit env proxy handling, then direct connection. - Custom CA handling remains separate from proxy route selection and still runs through the shared client builder. - No proxy URLs, PAC contents, or resolved platform details are exposed through the public config surface introduced here. ## Tests Adds or updates coverage for: - preserving default auth-client fallback behavior when no route config is provided; - injected environment-proxy fallback without mutating process environment; - existing login-server E2E flows using explicit `auth_route_config: None` to guard unchanged default behavior; - updated auth manager, login, logout, cloud-config, startup, and plugin-auth call sites passing route config explicitly. --- .../src/transport/remote_control/tests.rs | 3 + .../remote_control/tests/clients_tests.rs | 2 + .../remote_control/tests/pairing_tests.rs | 2 + .../src/transport/remote_control/websocket.rs | 3 + .../request_processors/account_processor.rs | 1 + codex-rs/cli/src/login.rs | 21 +- codex-rs/cli/src/main.rs | 10 +- codex-rs/cli/src/plugin_cmd.rs | 2 + codex-rs/cloud-config/src/bundle_loader.rs | 3 + codex-rs/cloud-config/src/service_tests.rs | 7 + codex-rs/cloud-tasks/src/util.rs | 1 + codex-rs/codex-client/src/lib.rs | 6 + codex-rs/codex-client/src/outbound_proxy.rs | 322 ++++++++++++++++++ .../codex-client/src/outbound_proxy_tests.rs | 133 ++++++++ codex-rs/core/src/config/mod.rs | 19 ++ codex-rs/core/tests/suite/client.rs | 1 + codex-rs/exec/src/lib.rs | 12 + codex-rs/login/src/auth/agent_identity.rs | 43 ++- codex-rs/login/src/auth/auth_tests.rs | 45 +++ .../login/src/auth/bedrock_api_key_tests.rs | 3 + codex-rs/login/src/auth/default_client.rs | 61 +++- codex-rs/login/src/auth/manager.rs | 94 ++++- .../login/src/auth/personal_access_token.rs | 21 +- .../src/auth/personal_access_token_tests.rs | 7 +- codex-rs/login/src/auth/revoke.rs | 6 +- codex-rs/login/src/device_code_auth.rs | 9 +- codex-rs/login/src/lib.rs | 2 + codex-rs/login/src/outbound_proxy.rs | 22 ++ codex-rs/login/src/server.rs | 36 +- codex-rs/login/tests/suite/auth_refresh.rs | 1 + .../login/tests/suite/device_code_login.rs | 3 + .../login/tests/suite/login_server_e2e.rs | 9 + codex-rs/login/tests/suite/logout.rs | 4 + codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/tui/src/lib.rs | 13 + codex-rs/tui/src/onboarding/auth.rs | 1 + codex-rs/tui/src/session_archive_commands.rs | 10 + 37 files changed, 885 insertions(+), 54 deletions(-) create mode 100644 codex-rs/codex-client/src/outbound_proxy.rs create mode 100644 codex-rs/codex-client/src/outbound_proxy_tests.rs create mode 100644 codex-rs/login/src/outbound_proxy.rs diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 6c453e6f9df8..44299c4b5723 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1041,6 +1041,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let (transport_event_tx, _transport_event_rx) = @@ -1870,6 +1871,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); @@ -1966,6 +1968,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_control_target = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 6354e435c917..21ec02ab0625 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -185,6 +185,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -270,6 +271,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 190526d720b9..380f13984eec 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -539,6 +539,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -808,6 +809,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_handle = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 239d5d9ea7a4..b9f1c7175209 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -2214,6 +2214,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2312,6 +2313,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2435,6 +2437,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); 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 4c8538e2b1cd..6aa624153de4 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -359,6 +359,7 @@ impl AccountRequestProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) }; #[cfg(debug_assertions)] diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index dc3d0af87d3f..5691c3cd1f59 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_login::AuthKeyringBackendKind; +use codex_login::AuthRouteConfig; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; @@ -119,11 +120,13 @@ async fn clear_existing_auth_before_login( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) { if let Err(err) = logout_with_revoke( codex_home, auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ) .await { @@ -136,11 +139,13 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> std::io::Result<()> { clear_existing_auth_before_login( &codex_home, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config.as_ref(), ) .await; @@ -150,6 +155,7 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ); let server = run_login_server(opts)?; @@ -169,12 +175,12 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - match login_with_chatgpt( config.codex_home.to_path_buf(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await { @@ -232,6 +238,7 @@ pub async fn run_login_with_access_token( std::process::exit(1); } + let auth_route_config = config.auth_route_config(); match login_with_access_token( &config.codex_home, &access_token, @@ -239,6 +246,7 @@ pub async fn run_login_with_access_token( config.forced_chatgpt_workspace_id.as_deref(), Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -307,10 +315,12 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); @@ -320,6 +330,7 @@ pub async fn run_login_with_device_code( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -352,10 +363,12 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; @@ -366,6 +379,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -409,12 +423,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_route_config = config.auth_route_config(); match CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -459,11 +475,13 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_route_config = config.auth_route_config(); match logout_with_revoke( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -536,6 +554,7 @@ mod tests { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9d278c21228f..8fd1358efd19 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1738,9 +1738,13 @@ async fn load_exec_server_remote_auth_provider( let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth = - CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) - .await?; + let auth_route_config = config.auth_route_config(); + let auth = CodexAuth::from_agent_identity_jwt( + &agent_identity_jwt, + Some(&config.chatgpt_base_url), + auth_route_config.as_ref(), + ) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 1fc670c8717f..433714a00c43 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -566,11 +566,13 @@ pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { return Some(CodexAuth::from_api_key(&api_key).api_auth_mode()); } + let auth_route_config = config.auth_route_config(); CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await .ok() diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 5ec1ab260f52..e4266c6195ba 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -7,6 +7,7 @@ use codex_config::CloudConfigBundleLoader; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -58,6 +59,7 @@ pub async fn cloud_config_bundle_loader_for_storage( credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, chatgpt_base_url: String, + auth_route_config: Option, ) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( codex_home.clone(), @@ -66,6 +68,7 @@ pub async fn cloud_config_bundle_loader_for_storage( /*forced_chatgpt_workspace_id*/ None, Some(chatgpt_base_url.clone()), keyring_backend_kind, + auth_route_config, ) .await; cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 96fed2f589a2..47b49d43bb1a 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -53,6 +53,7 @@ async fn auth_manager_with_api_key() -> Arc { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -83,6 +84,7 @@ async fn auth_manager_with_plan_and_identity( /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -108,6 +110,7 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc { task_id: Some("task-123".to_string()), }, "https://auth.openai.com/api/accounts", + /*auth_route_config*/ None, ) .await .expect("agent identity record should be complete"), @@ -686,6 +689,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -742,6 +746,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -806,6 +811,7 @@ async fn get_bundle_surfaces_auth_recovery_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -872,6 +878,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 65db955f1566..2a836f857931 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -52,6 +52,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option) -> fmt::Result { + f.write_str(match self { + Self::Auth => "auth", + Self::Api => "api", + Self::WebSocket => "wss", + Self::Other => "other", + }) + } +} + +/// Coarse failure class for route selection errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteFailureClass { + ProxyResolutionUnavailable, + ConnectTimeout, + ProxyAuthenticationRequired, + TlsError, + InvalidProxyConfig, + UnsupportedProxyScheme, + ResolverError, +} + +impl fmt::Display for RouteFailureClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable", + Self::ConnectTimeout => "connect_timeout", + Self::ProxyAuthenticationRequired => "proxy_407", + Self::TlsError => "tls_error", + Self::InvalidProxyConfig => "invalid_proxy_config", + Self::UnsupportedProxyScheme => "unsupported_proxy_scheme", + Self::ResolverError => "resolver_error", + }) + } +} + +/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing. +/// Resolved endpoints and platform details remain internal to the client builder. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutboundProxyConfig; + +impl OutboundProxyConfig { + pub const fn respect_system_proxy() -> Self { + Self + } +} + +/// Error while building a resolver-aware reqwest client. +#[derive(Debug, Error)] +pub enum BuildRouteAwareHttpClientError { + #[error(transparent)] + CustomCa(#[from] BuildCustomCaTransportError), + + #[error("Failed to configure outbound proxy selected for {route_class}")] + InvalidProxyConfig { route_class: ClientRouteClass }, +} + +impl From for io::Error { + fn from(error: BuildRouteAwareHttpClientError) -> Self { + match error { + BuildRouteAwareHttpClientError::CustomCa(error) => error.into(), + BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } => io::Error::other(error), + } + } +} + +/// Builds a reqwest client with conservative route selection and shared CA handling. +/// +/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after +/// a route is selected are returned without trying another route. +pub fn build_reqwest_client_for_route( + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + let builder = + configure_proxy_for_route(&ProcessEnv, builder, request_url, route_class, config)?; + build_reqwest_client_with_custom_ca(builder).map_err(Into::into) +} + +fn configure_proxy_for_route( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + if config.is_none() { + return Ok(builder); + } + let origin = RequestOrigin::parse(request_url); + + let Some(origin) = origin.as_ref() else { + return configure_env_proxy_handling(env, builder, /*origin*/ None, route_class); + }; + + match resolve_system_proxy(request_url, origin) { + SystemProxyDecision::Direct => Ok(builder.no_proxy()), + SystemProxyDecision::Proxy { url } => { + configure_concrete_proxy(builder, route_class, &url, /*no_proxy*/ None) + } + SystemProxyDecision::Unavailable { .. } => { + configure_env_proxy_handling(env, builder, Some(origin), route_class) + } + } +} + +fn configure_concrete_proxy( + builder: reqwest::ClientBuilder, + route_class: ClientRouteClass, + proxy_url: &str, + no_proxy: Option, +) -> Result { + let proxy = match reqwest::Proxy::all(proxy_url) { + Ok(proxy) => proxy, + Err(_source) => { + return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class }); + } + }; + Ok(builder.proxy(proxy.no_proxy(no_proxy))) +} + +fn configure_env_proxy_handling( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + origin: Option<&RequestOrigin>, + route_class: ClientRouteClass, +) -> Result { + if let Some(origin) = origin { + let proxy_url = match origin.scheme.as_str() { + "https" => { + proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + "http" => { + proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + _ => proxy_env_value(env, "ALL_PROXY"), + }; + if let Some(proxy_url) = proxy_url { + let no_proxy = proxy_env_value(env, "NO_PROXY") + .and_then(|value| reqwest::NoProxy::from_string(&value)); + return configure_concrete_proxy(builder, route_class, &proxy_url, no_proxy); + } + } + Ok(builder.no_proxy()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RequestOrigin { + scheme: String, + host: String, + port: u16, +} + +impl RequestOrigin { + fn parse(request_url: &str) -> Option { + let uri = request_url.parse::().ok()?; + 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), + _ => None, + })?; + Some(Self { scheme, host, port }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow( + dead_code, + reason = "Direct and Proxy are constructed by platform resolvers added in later PRs" +)] +enum SystemProxyDecision { + Direct, + Proxy { url: String }, + Unavailable { failure: RouteFailureClass }, +} + +fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + if let Some(decision) = cached_system_proxy_decision(request_url) { + return decision; + } + + let decision = resolve_platform_system_proxy(request_url, origin); + cache_system_proxy_decision(request_url, decision.clone()); + decision +} + +fn resolve_platform_system_proxy( + _request_url: &str, + _origin: &RequestOrigin, +) -> SystemProxyDecision { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } +} + +#[derive(Debug, Clone)] +struct CachedSystemProxyDecision { + decision: SystemProxyDecision, + expires_at: Instant, +} + +static SYSTEM_PROXY_CACHE: OnceLock>> = + OnceLock::new(); + +fn cached_system_proxy_decision(request_url: &str) -> Option { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().ok()?; + let cached = cache.get(request_url)?; + if cached.expires_at > Instant::now() { + return Some(cached.decision.clone()); + } + cache.remove(request_url); + None +} + +fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut cache) = cache.lock() { + insert_system_proxy_cache_entry(&mut cache, request_url, decision, Instant::now()); + } +} + +fn insert_system_proxy_cache_entry( + cache: &mut HashMap, + request_url: &str, + decision: SystemProxyDecision, + now: Instant, +) { + let ttl = match &decision { + SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => { + SYSTEM_PROXY_SUCCESS_CACHE_TTL + } + SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL, + }; + + cache.retain(|_, cached| cached.expires_at > now); + if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES + && !cache.contains_key(request_url) + && let Some(request_url_to_evict) = cache + .iter() + .min_by_key(|(_, cached)| cached.expires_at) + .map(|(request_url, _)| request_url.clone()) + { + cache.remove(&request_url_to_evict); + } + cache.insert( + request_url.to_string(), + CachedSystemProxyDecision { + decision, + expires_at: now + ttl, + }, + ); +} + +trait EnvSource { + fn var(&self, key: &str) -> Option; +} + +struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok() + } +} + +fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option { + let lower = upper.to_ascii_lowercase(); + env.var(upper) + .or_else(|| env.var(&lower)) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +#[path = "outbound_proxy_tests.rs"] +mod tests; diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs new file mode 100644 index 000000000000..9c713d28f1a0 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -0,0 +1,133 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io::Read; +use std::io::Write; + +struct MapEnv { + values: HashMap, +} + +impl EnvSource for MapEnv { + fn var(&self, key: &str) -> Option { + self.values.get(key).cloned() + } +} + +#[test] +fn proxy_env_value_matches_reqwest_casing_precedence() { + let env = MapEnv { + values: HashMap::from([ + ("HTTPS_PROXY".to_string(), "upper".to_string()), + ("https_proxy".to_string(), "lower".to_string()), + ("http_proxy".to_string(), "lower-only".to_string()), + ("ALL_PROXY".to_string(), String::new()), + ("all_proxy".to_string(), "masked".to_string()), + ]), + }; + + assert_eq!( + proxy_env_value(&env, "HTTPS_PROXY"), + Some("upper".to_string()) + ); + assert_eq!( + proxy_env_value(&env, "HTTP_PROXY"), + Some("lower-only".to_string()) + ); + assert_eq!(proxy_env_value(&env, "ALL_PROXY"), None); +} + +#[test] +fn environment_fallback_reads_injected_proxy_environment() { + let env = MapEnv { + values: HashMap::from([("HTTPS_PROXY".to_string(), "://invalid".to_string())]), + }; + let origin = RequestOrigin::parse("https://auth.openai.com/oauth/token").expect("valid URL"); + let result = configure_env_proxy_handling( + &env, + reqwest::Client::builder(), + Some(&origin), + ClientRouteClass::Auth, + ); + + assert!(matches!( + result, + Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { + route_class: ClientRouteClass::Auth, + }) + )); +} + +#[tokio::test] +async fn enabled_environment_proxy_routes_request_through_proxy() { + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).expect("local proxy listener should bind"); + let proxy_addr = listener + .local_addr() + .expect("local proxy listener should have an address"); + let proxy_thread = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("proxy should accept a request"); + let mut buffer = [0_u8; 4096]; + let size = stream.read(&mut buffer).expect("proxy should read request"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .expect("proxy should write response"); + String::from_utf8_lossy(&buffer[..size]).into_owned() + }); + let env = MapEnv { + values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]), + }; + let request_url = "http://enabled-proxy.test/proxy-check"; + let config = OutboundProxyConfig::respect_system_proxy(); + let builder = configure_proxy_for_route( + &env, + reqwest::Client::builder().timeout(Duration::from_secs(2)), + request_url, + ClientRouteClass::Auth, + Some(&config), + ) + .expect("enabled proxy route should configure"); + + let response = builder + .build() + .expect("proxy client should build") + .get(request_url) + .send() + .await + .expect("request should use local proxy"); + let proxy_request = proxy_thread.join().expect("proxy thread should finish"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + proxy_request.lines().next(), + Some("GET http://enabled-proxy.test/proxy-check HTTP/1.1") + ); +} + +#[test] +fn unavailable_system_proxy_decision_is_cached() { + let request_url = "https://unavailable-cache.test/oauth/token"; + let decision = SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + + cache_system_proxy_decision(request_url, decision.clone()); + + assert_eq!(cached_system_proxy_decision(request_url), Some(decision)); +} + +#[test] +fn system_proxy_cache_is_bounded() { + let mut cache = HashMap::new(); + let now = Instant::now(); + + for index in 0..=SYSTEM_PROXY_CACHE_MAX_ENTRIES { + insert_system_proxy_cache_entry( + &mut cache, + &format!("https://bounded-cache.test/{index}"), + SystemProxyDecision::Direct, + now, + ); + } + + assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES); +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 17161e1a3c4c..19d4bb44c1e0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -72,6 +72,7 @@ use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; +use codex_login::AuthRouteConfig; use codex_mcp::McpConfig; use codex_mcp::McpPluginAttribution; use codex_mcp::McpServerRegistration; @@ -1201,6 +1202,10 @@ impl AuthManagerConfig for Config { fn chatgpt_base_url(&self) -> String { self.chatgpt_base_url.clone() } + + fn auth_route_config(&self) -> Option { + Config::auth_route_config(self) + } } #[derive(Clone, Default)] @@ -1454,6 +1459,11 @@ impl Config { } } + pub fn auth_route_config(&self) -> Option { + self.respect_system_proxy + .then(AuthRouteConfig::respect_system_proxy) + } + /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { PluginsConfigInput::new( @@ -2737,6 +2747,15 @@ pub fn resolve_bootstrap_respect_system_proxy( Ok(features.get().enabled(Feature::RespectSystemProxy)) } +/// Resolves auth route settings for the initial cloud-config bootstrap. +pub fn resolve_bootstrap_auth_route_config( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result> { + resolve_bootstrap_respect_system_proxy(cfg, feature_requirements) + .map(|enabled| enabled.then(AuthRouteConfig::respect_system_proxy)) +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cbd014c3f7ee..d0e68c8a9518 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1385,6 +1385,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("Failed to load CodexAuth") diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a04e2585522f..08c905c00131 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -66,6 +66,7 @@ use codex_core::config::ConfigTomlLoadResult; use codex_core::config::find_codex_home; use codex_core::config::load_config_toml_with_layer_stack; use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use codex_core::config::resolve_bootstrap_auth_route_config; use codex_core::config::resolve_oss_provider; use codex_core::config::resolve_profile_v2_config_path; use codex_core::find_thread_meta_by_name_str; @@ -349,6 +350,14 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -357,6 +366,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); @@ -468,6 +478,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); + let auth_route_config = config.auth_route_config(); if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, @@ -475,6 +486,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + auth_route_config, }) .await { diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 370af883c001..a164c7e3a503 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -3,6 +3,9 @@ use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; use codex_agent_identity::ChatGptEnvironment; +use codex_agent_identity::agent_identity_jwks_url; +use codex_agent_identity::agent_registration_url; +use codex_agent_identity::agent_task_registration_url; use codex_agent_identity::build_abom; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; @@ -15,7 +18,8 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::SessionSource; use thiserror::Error; -use crate::default_client::build_reqwest_client; +use crate::default_client::build_default_auth_reqwest_client; +use crate::outbound_proxy::AuthRouteConfig; use super::storage::AgentIdentityAuthRecord; @@ -96,13 +100,18 @@ impl AgentIdentityAuth { pub async fn from_record( mut record: AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key) .map_err(std::io::Error::other)?; if record_needs_task_registration(&record) { record.task_id = Some( - register_task_for_record_with_retries(&record, agent_identity_authapi_base_url) - .await?, + register_task_for_record_with_retries( + &record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, ); } Ok(Self { @@ -114,9 +123,10 @@ impl AgentIdentityAuth { jwt: &str, chatgpt_base_url: &str, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { - let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?; - Self::from_record(record, agent_identity_authapi_base_url).await + let record = verified_record_from_jwt(jwt, chatgpt_base_url, auth_route_config).await?; + Self::from_record(record, agent_identity_authapi_base_url, auth_route_config).await } #[cfg(test)] @@ -163,9 +173,11 @@ pub(super) async fn register_managed_chatgpt_agent_identity( binding: ManagedChatGptAgentIdentityBinding, agent_identity_authapi_base_url: &str, session_source: SessionSource, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let key_material = generate_agent_key_material().map_err(std::io::Error::other)?; - let client = build_reqwest_client(); + let registration_url = agent_registration_url(agent_identity_authapi_base_url); + let client = build_default_auth_reqwest_client(®istration_url, auth_route_config)?; let runtime_id = retry_registration(|| async { register_agent_identity( &client, @@ -200,7 +212,7 @@ pub(super) async fn register_managed_chatgpt_agent_identity( chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp, task_id: None, }; - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) + AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config) .await .map_err(|err| classify_bootstrap_error("agent task registration", err)) } @@ -208,9 +220,12 @@ pub(super) async fn register_managed_chatgpt_agent_identity( pub(super) async fn verified_record_from_jwt( jwt: &str, chatgpt_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; - let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url) + let jwks_url = agent_identity_jwks_url(chatgpt_base_url); + let client = build_default_auth_reqwest_client(&jwks_url, auth_route_config)?; + let jwks = fetch_agent_identity_jwks(&client, chatgpt_base_url) .await .map_err(std::io::Error::other)?; let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?; @@ -285,19 +300,24 @@ where async fn register_task_for_record_with_retries( record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { + let task_registration_url = + agent_task_registration_url(agent_identity_authapi_base_url, &record.agent_runtime_id); + let client = build_default_auth_reqwest_client(&task_registration_url, auth_route_config)?; retry_registration(|| async { - register_task_for_record(record, agent_identity_authapi_base_url).await + register_task_for_record(&client, record, agent_identity_authapi_base_url).await }) .await } async fn register_task_for_record( + client: &reqwest::Client, record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, ) -> std::io::Result { register_agent_task( - &build_reqwest_client(), + client, agent_identity_authapi_base_url, key_for_record(record), ) @@ -370,6 +390,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -414,6 +435,7 @@ mod tests { &jwt, &format!("{}/backend-api", server.uri()), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -456,6 +478,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index b52f03b7068b..fbb6aac8ea8a 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -122,6 +122,7 @@ async fn login_with_access_token_writes_agent_identity_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("login_with_access_token should succeed"); @@ -181,6 +182,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -226,6 +228,7 @@ async fn login_with_access_token_writes_only_personal_access_token() { Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -278,6 +281,7 @@ async fn login_with_access_token_rejects_personal_access_token_workspace_mismatc Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("personal access token workspace mismatch should fail"); @@ -310,6 +314,7 @@ async fn login_with_access_token_rejects_invalid_personal_access_token() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid personal access token should fail"); @@ -333,6 +338,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid access token should fail"); @@ -364,6 +370,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -373,6 +380,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::JwtOnly, /*agent_identity_authapi_base_url*/ None, /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -403,6 +411,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -412,6 +421,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -442,6 +452,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should reload"); @@ -450,6 +461,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -482,6 +494,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -510,6 +523,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -546,6 +560,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -563,6 +578,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -605,6 +621,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -625,6 +642,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -659,6 +677,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -676,6 +695,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -717,6 +737,7 @@ async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> an &agent_identity, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect_err("agent identity jwt task retry exhaustion should fail"); @@ -747,6 +768,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("unsigned access token should fail"); @@ -768,6 +790,7 @@ async fn missing_auth_json_returns_none() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("call should succeed"); @@ -797,6 +820,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -858,6 +882,7 @@ async fn loads_api_key_from_auth_json() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -908,6 +933,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let managed = UnauthorizedRecovery { @@ -952,6 +978,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -972,6 +999,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("updated auth should parse"); @@ -1277,6 +1305,7 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, + auth_route_config: None, } } @@ -1359,6 +1388,7 @@ async fn load_auth_reads_access_token_from_env() { Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1406,6 +1436,7 @@ async fn load_auth_reads_personal_access_token_from_env() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1459,6 +1490,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1498,6 +1530,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1509,6 +1542,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1542,6 +1576,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -1574,6 +1609,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1670,6 +1706,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1681,6 +1718,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, + auth_route_config: None, }; let err = super::enforce_login_restrictions(&config) @@ -1804,6 +1842,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), + auth_route_config: None, }; let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url( @@ -2056,6 +2095,7 @@ async fn assert_agent_identity_plan_alias( &jwt, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect("agent identity auth"); @@ -2087,6 +2127,7 @@ async fn plan_type_maps_known_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2118,6 +2159,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2152,6 +2194,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2186,6 +2229,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2217,6 +2261,7 @@ async fn missing_plan_type_maps_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") 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 691d8bbc5d2a..7db2672ce36a 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -63,6 +63,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -111,6 +112,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -135,6 +137,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index ee51edf9b56d..f72727bfca97 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -5,8 +5,11 @@ //! workspace. use codex_client::BuildCustomCaTransportError; +use codex_client::BuildRouteAwareHttpClientError; +use codex_client::ClientRouteClass; use codex_client::CodexHttpClient; pub use codex_client::CodexRequestBuilder; +use codex_client::build_reqwest_client_for_route; use codex_client::build_reqwest_client_with_custom_ca; use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; @@ -17,6 +20,8 @@ use std::sync::LazyLock; use std::sync::Mutex; use std::sync::RwLock; +use crate::outbound_proxy::AuthRouteConfig; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -189,6 +194,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { } /// Create an HTTP client with default `originator` and `User-Agent` headers set. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. pub fn create_client() -> CodexHttpClient { let inner = build_reqwest_client(); CodexHttpClient::new(inner) @@ -200,6 +208,10 @@ pub fn create_client() -> CodexHttpClient { /// policy, then layers in shared custom CA handling from `CODEX_CA_CERTIFICATE` / /// `SSL_CERT_FILE`. The function remains infallible for compatibility with existing call sites, so /// a custom-CA or builder failure is logged and falls back to `reqwest::Client::new()`. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. Auth callers with route settings must use +/// `build_default_auth_reqwest_client` or `create_default_auth_client`. pub fn build_reqwest_client() -> reqwest::Client { try_build_reqwest_client().unwrap_or_else(|error| { tracing::warn!(error = %error, "failed to build default reqwest client"); @@ -220,13 +232,58 @@ pub fn build_reqwest_client() -> reqwest::Client { /// Callers that need a structured CA-loading failure instead of the legacy logged fallback can use /// this method directly. pub fn try_build_reqwest_client() -> Result { + build_reqwest_client_with_custom_ca(default_reqwest_client_builder()) +} + +fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { builder = builder.no_proxy(); } - builder = with_chatgpt_cloudflare_cookie_store(builder); + with_chatgpt_cloudflare_cookie_store(builder) +} + +/// Builds a raw reqwest client for an auth endpoint without Codex default headers. +pub(crate) fn build_raw_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_reqwest_client_for_route( + reqwest::Client::builder(), + endpoint, + ClientRouteClass::Auth, + auth_route_config.map(AuthRouteConfig::route_config), + ) +} + +/// Builds the default Codex reqwest client for an auth endpoint. +pub(crate) fn build_default_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else { + return Ok(build_reqwest_client()); + }; + + if is_sandboxed() { + // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed + // separately through network-proxy. + return Ok(build_reqwest_client()); + } + build_reqwest_client_for_route( + default_reqwest_client_builder(), + endpoint, + ClientRouteClass::Auth, + Some(route_config), + ) +} - build_reqwest_client_with_custom_ca(builder) +/// Builds the default Codex HTTP client wrapper for an auth endpoint. +pub(crate) fn create_default_auth_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new) } pub fn default_headers() -> HeaderMap { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 138bdad5b9dd..a762bd54668e 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -49,6 +49,8 @@ use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; @@ -237,6 +239,7 @@ impl CodexAuth { chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); if auth_mode == ApiAuthMode::ApiKey { @@ -263,14 +266,18 @@ impl CodexAuth { &jwt, &base_url, agent_identity_authapi_base_url, + auth_route_config, ) .await?; return Ok(Self::AgentIdentity(auth)); } AgentIdentityStorage::Record(record) => { - let auth = - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?; return Ok(Self::AgentIdentity(auth)); } } @@ -281,7 +288,8 @@ impl CodexAuth { "personal access token auth is missing a personal access token.", )); }; - return Self::from_personal_access_token(personal_access_token).await; + return Self::from_personal_access_token(personal_access_token, auth_route_config) + .await; } if auth_mode == ApiAuthMode::BedrockApiKey { let Some(auth) = auth_dot_json.bedrock_api_key else { @@ -293,9 +301,10 @@ impl CodexAuth { } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); + let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?; let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client: create_client(), + client, }; match auth_mode { @@ -324,6 +333,7 @@ impl CodexAuth { auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url).ok(); @@ -335,6 +345,7 @@ impl CodexAuth { chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config, ) .await } @@ -342,12 +353,14 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?; Self::from_agent_identity_jwt_with_authapi_base_url( jwt, chatgpt_base_url, &agent_identity_authapi_base_url, + auth_route_config, ) .await } @@ -356,19 +369,29 @@ impl CodexAuth { jwt: &str, chatgpt_base_url: Option<&str>, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let base_url = chatgpt_base_url .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); Ok(Self::AgentIdentity( - AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?, + AgentIdentityAuth::from_jwt( + jwt, + &base_url, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, )) } - pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + pub async fn from_personal_access_token( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { Ok(Self::PersonalAccessToken( - PersonalAccessTokenAuth::load(access_token).await?, + PersonalAccessTokenAuth::load(access_token, auth_route_config).await?, )) } @@ -568,6 +591,7 @@ impl CodexAuth { policy: AgentIdentityAuthPolicy, agent_identity_authapi_base_url: Option<&str>, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result> { match self { @@ -583,6 +607,7 @@ impl CodexAuth { self.ensure_managed_chatgpt_agent_identity( require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, forced_chatgpt_workspace_id, + auth_route_config, session_source, ) .await @@ -595,6 +620,7 @@ impl CodexAuth { &self, agent_identity_authapi_base_url: &str, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result { let binding = @@ -607,9 +633,13 @@ impl CodexAuth { && record_matches_managed_chatgpt_binding(&record, &binding) { let should_persist = record_needs_task_registration(&record); - let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await - .map_err(|err| classify_bootstrap_error("agent task registration", err))?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await + .map_err(|err| classify_bootstrap_error("agent task registration", err))?; if should_persist { self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; } @@ -620,6 +650,7 @@ impl CodexAuth { binding, agent_identity_authapi_base_url, session_source, + auth_route_config, ) .await?; self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; @@ -789,6 +820,7 @@ pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_dot_json = match load_auth_dot_json( codex_home, @@ -801,7 +833,7 @@ pub async fn logout_with_revoke( None } }; - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } logout_all_stores( @@ -843,10 +875,11 @@ pub async fn login_with_access_token( forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result<()> { let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; AuthDotJson { // Infer PAT auth from the credential field so older Codex builds can still @@ -865,7 +898,7 @@ pub async fn login_with_access_token( .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - verified_record_from_jwt(jwt, &base_url).await?; + verified_record_from_jwt(jwt, &base_url, auth_route_config).await?; AuthDotJson { auth_mode: Some(ApiAuthMode::AgentIdentity), openai_api_key: None, @@ -954,8 +987,10 @@ pub struct AuthConfig { pub forced_login_method: Option, pub chatgpt_base_url: Option, pub forced_chatgpt_workspace_id: Option>, + pub auth_route_config: Option, } +/// Enforces configured login restrictions using auth-owned HTTP settings. pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok(); @@ -978,6 +1013,7 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, agent_identity_authapi_base_url, + config.auth_route_config.as_ref(), ) .await? else { @@ -1113,6 +1149,7 @@ fn logout_all_stores( Ok(removed_ephemeral || removed_managed) } +#[allow(clippy::too_many_arguments)] async fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, @@ -1121,6 +1158,7 @@ async fn load_auth( chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -1142,6 +1180,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1153,7 +1192,7 @@ async fn load_auth( if let Some(access_token) = read_codex_access_token_from_env() { return match classify_codex_access_token(&access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } @@ -1162,6 +1201,7 @@ async fn load_auth( jwt, chatgpt_base_url, require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, + auth_route_config, ) } .await @@ -1192,6 +1232,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1237,7 +1278,6 @@ async fn request_chatgpt_token_refresh( grant_type: "refresh_token", refresh_token, }; - let endpoint = refresh_token_endpoint(); // Use shared client factory to include standard headers @@ -1702,6 +1742,7 @@ pub struct AuthManager { refresh_lock: Semaphore, agent_identity_lock: Semaphore, external_auth: RwLock>>, + auth_route_config: Option, } /// Configuration view required to construct a shared [`AuthManager`]. @@ -1725,6 +1766,9 @@ pub trait AuthManagerConfig { /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; + + /// Returns route-selection settings for auth-owned clients. + fn auth_route_config(&self) -> Option; } impl Debug for AuthManager { @@ -1743,6 +1787,7 @@ impl Debug for AuthManager { &self.forced_chatgpt_workspace_id, ) .field("chatgpt_base_url", &self.chatgpt_base_url) + .field("auth_route_config", &self.auth_route_config) .field("has_external_auth", &self.has_external_auth()) .finish_non_exhaustive() } @@ -1764,6 +1809,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok(); @@ -1775,6 +1821,7 @@ impl AuthManager { chatgpt_base_url.as_deref(), keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config.as_ref(), ) .await .ok() @@ -1796,6 +1843,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config, } } @@ -1820,6 +1868,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1843,6 +1892,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1874,6 +1924,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1897,6 +1948,7 @@ impl AuthManager { external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc )), + auth_route_config: None, }) } @@ -1958,6 +2010,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await; @@ -1966,6 +2019,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await @@ -2074,6 +2128,7 @@ impl AuthManager { self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, self.agent_identity_authapi_base_url.as_deref(), + self.auth_route_config.as_ref(), ) .await .ok() @@ -2149,6 +2204,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Arc { Arc::new( Self::new( @@ -2158,6 +2214,7 @@ impl AuthManager { forced_chatgpt_workspace_id, chatgpt_base_url, keyring_backend_kind, + auth_route_config, ) .await, ) @@ -2175,6 +2232,7 @@ impl AuthManager { config.forced_chatgpt_workspace_id(), Some(config.chatgpt_base_url()), config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await } @@ -2328,7 +2386,9 @@ impl AuthManager { let auth_dot_json = self .auth_cached() .and_then(|auth| auth.get_current_auth_json()); - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = + revoke_auth_tokens(auth_dot_json.as_ref(), self.auth_route_config.as_ref()).await + { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } let result = logout_all_stores( diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index b99092f51f19..5c9fcc3bdd2b 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -5,7 +5,8 @@ use serde::Deserialize; use std::env; use std::fmt; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; @@ -36,13 +37,18 @@ impl fmt::Debug for PersonalAccessTokenAuth { } impl PersonalAccessTokenAuth { - pub(super) async fn load(access_token: &str) -> std::io::Result { + pub(super) async fn load( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) .ok() .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) .filter(|base_url| !base_url.is_empty()) .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); - hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + let endpoint = whoami_endpoint(&authapi_base_url); + let client = create_default_auth_client(&endpoint, auth_route_config)?; + hydrate_personal_access_token(&client, &endpoint, access_token).await } pub fn access_token(&self) -> &str { @@ -72,12 +78,11 @@ impl PersonalAccessTokenAuth { async fn hydrate_personal_access_token( client: &CodexHttpClient, - authapi_base_url: &str, + endpoint: &str, access_token: &str, ) -> std::io::Result { - let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); let response = client - .get(&endpoint) + .get(endpoint) .bearer_auth(access_token) .send() .await @@ -107,6 +112,10 @@ async fn hydrate_personal_access_token( }) } +fn whoami_endpoint(authapi_base_url: &str) -> String { + format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')) +} + #[cfg(test)] #[path = "personal_access_token_tests.rs"] mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs index ac6ee12265eb..4d1b5e1ef756 100644 --- a/codex-rs/login/src/auth/personal_access_token_tests.rs +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::default_client::create_client; use pretty_assertions::assert_eq; use serde_json::json; use wiremock::Mock; @@ -29,7 +30,8 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { .mount(&server) .await; - let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await .expect("personal access token hydration should succeed"); @@ -59,7 +61,8 @@ async fn hydrate_rejects_missing_email() { .mount(&server) .await; - let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await .expect_err("personal access token hydration should reject missing email"); diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 22de353d3a86..0f7e313c1cb1 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -16,7 +16,8 @@ use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use super::manager::oauth_client_id; use super::storage::AuthDotJson; use super::util::try_parse_error_message; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); @@ -53,13 +54,14 @@ struct RevokeTokenRequest<'a> { pub(super) async fn revoke_auth_tokens( auth_dot_json: Option<&AuthDotJson>, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result<(), std::io::Error> { let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else { return Ok(()); }; - let client = create_client(); let endpoint = revoke_token_endpoint(); + let client = create_default_auth_client(&endpoint, auth_route_config)?; revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 6f18c5984c97..70e6dff22538 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -6,9 +6,9 @@ use serde::de::{self}; use std::time::Duration; use std::time::Instant; +use crate::default_client::build_raw_auth_reqwest_client; use crate::pkce::PkceCodes; use crate::server::ServerOptions; -use codex_client::build_reqwest_client_with_custom_ca; use std::io; const ANSI_BLUE: &str = "\x1b[94m"; @@ -157,8 +157,10 @@ fn print_device_code_prompt(verification_url: &str, code: &str) { } pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); + // The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint + // paths are not resolved separately. + let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; @@ -174,8 +176,8 @@ pub async fn complete_device_code_login( opts: ServerOptions, device_code: DeviceCode, ) -> std::io::Result<()> { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); + let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let code_resp = poll_for_token( @@ -199,6 +201,7 @@ pub async fn complete_device_code_login( &redirect_uri, &pkce, &code_resp.authorization_code, + opts.auth_route_config.as_ref(), ) .await .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 2b8e48b4a541..44c2f1c4d343 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -3,6 +3,7 @@ pub mod auth_env_telemetry; pub mod token_data; mod device_code_auth; +mod outbound_proxy; mod pkce; mod server; @@ -53,4 +54,5 @@ pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; pub use auth_env_telemetry::collect_auth_env_telemetry; +pub use outbound_proxy::AuthRouteConfig; pub use token_data::TokenData; diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs new file mode 100644 index 000000000000..bf7dfbeb4d8c --- /dev/null +++ b/codex-rs/login/src/outbound_proxy.rs @@ -0,0 +1,22 @@ +use codex_client::OutboundProxyConfig; + +/// Auth-layer adapter around client-owned proxy policy. +/// +/// `AuthConfig` carries this value while endpoint resolution and platform details remain in the +/// client layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthRouteConfig { + route_config: OutboundProxyConfig, +} + +impl AuthRouteConfig { + pub fn respect_system_proxy() -> Self { + Self { + route_config: OutboundProxyConfig::respect_system_proxy(), + } + } + + pub(crate) fn route_config(&self) -> &OutboundProxyConfig { + &self.route_config + } +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 67d5e8c663a1..c450da0f68d5 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -27,7 +27,9 @@ use std::time::Duration; use crate::auth::AuthDotJson; use crate::auth::AuthKeyringBackendKind; use crate::auth::save_auth; +use crate::default_client::build_raw_auth_reqwest_client; use crate::default_client::originator; +use crate::outbound_proxy::AuthRouteConfig; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use crate::token_data::TokenData; @@ -35,7 +37,6 @@ use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; -use codex_client::build_reqwest_client_with_custom_ca; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_template::Template; use rand::RngCore; @@ -71,6 +72,7 @@ pub struct ServerOptions { pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, + pub auth_route_config: Option, } impl ServerOptions { @@ -81,6 +83,7 @@ impl ServerOptions { forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { Self { codex_home, @@ -93,6 +96,7 @@ impl ServerOptions { codex_streamlined_login: false, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, } } } @@ -336,8 +340,15 @@ async fn process_request( } }; - match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code) - .await + match exchange_code_for_tokens( + &opts.issuer, + &opts.client_id, + redirect_uri, + pkce, + &code, + opts.auth_route_config.as_ref(), + ) + .await { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( @@ -353,9 +364,14 @@ async fn process_request( ); } // Obtain API key via token-exchange and persist - let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) - .await - .ok(); + let api_key = obtain_api_key( + &opts.issuer, + &opts.client_id, + &tokens.id_token, + opts.auth_route_config.as_ref(), + ) + .await + .ok(); if let Err(err) = persist_tokens_async( &opts.codex_home, api_key.clone(), @@ -719,6 +735,7 @@ pub(crate) async fn exchange_code_for_tokens( redirect_uri: &str, pkce: &PkceCodes, code: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { #[derive(serde::Deserialize)] struct TokenResponse { @@ -727,7 +744,9 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + // The route selected for the issuer is reused for token exchange; the token endpoint path is + // not resolved separately. + let client = build_raw_auth_reqwest_client(issuer.trim_end_matches('/'), auth_route_config)?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); info!( issuer = %sanitize_url_for_logging(issuer), @@ -1129,14 +1148,15 @@ pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, id_token: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { // Token exchange for an API key access token #[derive(serde::Deserialize)] struct ExchangeResp { access_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); + let client = build_raw_auth_reqwest_client(&token_endpoint, auth_route_config)?; let resp = client .post(token_endpoint) .header("Content-Type", "application/x-www-form-urlencoded") diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index af892d76a095..9c5eb936b190 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -1220,6 +1220,7 @@ impl RefreshTokenTestContext { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 78adc84c24ee..83cae8af5fc3 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -112,6 +112,7 @@ fn server_opts( /*forced_chatgpt_workspace_id*/ None, cli_auth_credentials_store_mode, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -277,6 +278,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -332,6 +334,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 3d7c5a180b21..6f7b4e4f83a3 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -122,6 +122,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -185,6 +186,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -226,6 +228,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< let opts = ServerOptions { codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -268,6 +271,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -329,6 +333,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -398,6 +403,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -507,6 +513,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -545,6 +552,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let first_opts = ServerOptions { codex_home: first_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), port: 0, @@ -567,6 +575,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let second_opts = ServerOptions { codex_home: second_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: login_port, diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 3c47bc88b8ad..e8f9981a8a7f 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -61,6 +61,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -119,6 +120,7 @@ async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> R codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -161,6 +163,7 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -204,6 +207,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; save_auth( diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index f7006ca90531..eb9c5a893210 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -232,6 +232,7 @@ c2ln", AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("auth should load") diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c5cf1e185307..290430604d3e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -10,6 +10,7 @@ use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::ConfigTomlLoadResult; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use crate::legacy_core::format_exec_policy_error_with_source; @@ -957,6 +958,14 @@ pub async fn run_main( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -965,6 +974,7 @@ pub async fn run_main( .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; @@ -1154,6 +1164,7 @@ pub async fn run_main( } if !app_server_target.uses_remote_workspace() { + let auth_route_config = config.auth_route_config(); #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), @@ -1162,6 +1173,7 @@ pub async fn run_main( forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + auth_route_config, }) .await { @@ -1429,6 +1441,7 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, initial_config.auth_keyring_backend_kind(), initial_config.chatgpt_base_url.clone(), + initial_config.auth_route_config(), ) .await; } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0170ae49d480..8ba409f7c9ed 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1040,6 +1040,7 @@ mod tests { AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), "https://chatgpt.com/backend-api/".to_string(), + /*auth_route_config*/ None, ) .await, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 852aaad5ce58..56bcfae6ec4f 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -13,6 +13,7 @@ use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use codex_app_server_protocol::Thread as AppServerThread; @@ -329,12 +330,21 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; From 5a67d898a58cebc075ffea2643009217ed3e76f1 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Mon, 22 Jun 2026 13:19:40 -0700 Subject: [PATCH 03/12] Allow ChatGPT accounts without email (#28991) # Summary Codex required every ChatGPT account to have an email address. A service-account personal access token can return valid account metadata without one, so PAT login failed while decoding the metadata response. This change makes email optional in the account metadata type that owns it and preserves that absence through authentication, provider account state, the app-server API, generated clients, and TUI bootstrap. Existing accounts with email addresses keep the same behavior. ## Behavior-changing call sites | Call site | Behavior after this change | | --- | --- | | `login/src/auth/personal_access_token.rs` | PAT metadata accepts a missing or null email and retains `None`. | | `agent-identity/src/lib.rs` | Agent Identity JWT claims accept an omitted email. | | `login/src/auth/storage.rs` and `login/src/auth/agent_identity.rs` | Stored and managed Agent Identity records carry `Option`. Deserialization maps the legacy empty-string sentinel to `None`. | | `login/src/auth/manager.rs` | `get_account_email` returns the stored option, and managed identity bootstrap no longer converts `None` to an empty string. | | `model-provider/src/provider.rs` and `protocol/src/account.rs` | A ChatGPT provider account requires a plan type but may carry no email. | | `app-server-protocol/src/protocol/v2/account.rs` | `account/read` keeps the `email` field on the wire and returns `null` when the account has no email. Generated TypeScript and JSON schemas describe a required, nullable field. | | `sdk/python/src/openai_codex/generated/v2_all.py` | The generated Python `ChatgptAccount` model accepts `None` for email. | | `tui/src/app_server_session.rs` | Email-less ChatGPT accounts bootstrap normally, keep external feedback routing, omit account-email telemetry, and display the plan in account status. | ## Design decisions - Missing email remains `None` at every layer. The code never uses an empty string as a substitute. - The app-server response includes `"email": null` instead of omitting the field. Clients retain a stable response shape. - Plan type remains required for provider account state. This change relaxes only the email assumption. ## Testing Tests: affected test targets compile, scoped Clippy and formatting pass, a focused TUI snapshot covers plan-only account status, real before/after PAT login smoke covers metadata without email, app-server smoke covers `account/read` with `email: null`, and a regression smoke covers an existing email-bearing PAT. Unit tests run in CI. ## Evidence Visual smoke evidence will be attached here. --- codex-rs/Cargo.lock | 1 + codex-rs/agent-identity/src/lib.rs | 28 ++++++- .../codex_app_server_protocol.schemas.json | 5 +- .../codex_app_server_protocol.v2.schemas.json | 5 +- .../schema/json/v2/GetAccountResponse.json | 5 +- .../schema/typescript/v2/Account.ts | 2 +- .../src/protocol/common.rs | 15 +++- .../src/protocol/v2/account.rs | 12 ++- codex-rs/app-server/README.md | 2 + codex-rs/app-server/tests/suite/auth.rs | 36 ++++++++- codex-rs/app-server/tests/suite/v2/account.rs | 53 +++++++++++- .../suite/v2/rate_limit_reset_credits.rs | 11 ++- codex-rs/cloud-config/src/service_tests.rs | 2 +- codex-rs/login/src/auth/agent_identity.rs | 8 +- codex-rs/login/src/auth/auth_tests.rs | 4 +- codex-rs/login/src/auth/manager.rs | 6 +- .../login/src/auth/personal_access_token.rs | 6 +- .../src/auth/personal_access_token_tests.rs | 23 ++++-- codex-rs/login/src/auth/storage.rs | 26 +++++- codex-rs/login/src/auth/storage_tests.rs | 81 ++++++++++++++++++- codex-rs/model-provider/src/provider.rs | 25 +++--- codex-rs/protocol/src/account.rs | 2 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app_server_session.rs | 9 ++- ...shot_shows_chatgpt_plan_without_email.snap | 20 +++++ codex-rs/tui/src/status/tests.rs | 65 +++++++++++++++ sdk/python/scripts/update_sdk_artifacts.py | 53 ++++++++++++ .../src/openai_codex/generated/v2_all.py | 2 +- .../test_artifact_workflow_and_binaries.py | 38 +++++++++ 29 files changed, 485 insertions(+), 61 deletions(-) create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f35402efd08e..ae50cb5c853d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4016,6 +4016,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "app_test_support", "arboard", "assert_matches", "base64 0.22.1", diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index 0e29a387008e..267fdf53d8f4 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -119,7 +119,7 @@ pub struct AgentIdentityJwtClaims { pub agent_private_key: String, pub account_id: String, pub chatgpt_user_id: String, - pub email: String, + pub email: Option, pub plan_type: AuthPlanType, pub chatgpt_account_is_fedramp: bool, } @@ -667,13 +667,33 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, } ); } + #[test] + fn decode_agent_identity_jwt_accepts_missing_email() { + let jwt = jwt_with_payload(serde_json::json!({ + "iss": AGENT_IDENTITY_JWT_ISSUER, + "aud": AGENT_IDENTITY_JWT_AUDIENCE, + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + + let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode"); + + assert_eq!(claims.email, None); + } + #[test] fn decode_agent_identity_jwt_maps_raw_plan_aliases() { let jwt = jwt_with_payload(serde_json::json!({ @@ -707,7 +727,7 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; @@ -739,7 +759,7 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; 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 3a2af11db38a..f44e2f37ae93 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 @@ -5900,7 +5900,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/v2/PlanType" 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 286fc0e2f65d..5a8214b3a340 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 @@ -26,7 +26,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 62b520de410f..ca641799edb6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -22,7 +22,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts index 09f74469b74f..1b7953e5df18 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -4,4 +4,4 @@ import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource"; import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9bcc6a86d000..71773f2fc83d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2854,7 +2854,7 @@ mod tests { ); let chatgpt = v2::Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Plus, }; assert_eq!( @@ -2866,6 +2866,19 @@ mod tests { serde_json::to_value(&chatgpt)?, ); + let chatgpt_without_email = v2::Account::Chatgpt { + email: None, + plan_type: PlanType::Pro, + }; + assert_eq!( + json!({ + "type": "chatgpt", + "email": null, + "planType": "pro", + }), + serde_json::to_value(&chatgpt_without_email)?, + ); + let codex_managed_bedrock = v2::Account::AmazonBedrock { credential_source: AmazonBedrockCredentialSource::CodexManaged, }; 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 348d6810fc89..635d001bbad1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -25,7 +25,11 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, + Chatgpt { + #[schemars(required, schema_with = "nullable_string_schema")] + email: Option, + plan_type: PlanType, + }, #[serde(rename = "amazonBedrock", rename_all = "camelCase")] #[ts(rename = "amazonBedrock", rename_all = "camelCase")] @@ -35,6 +39,12 @@ pub enum Account { }, } +fn nullable_string_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + generator.subschema_for::>() +} + fn default_bedrock_credential_source() -> AmazonBedrockCredentialSource { AmazonBedrockCredentialSource::AwsManaged } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e70db8bba1f3..83eec5379c60 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1941,6 +1941,7 @@ Response examples: { "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) { "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": null, "planType": "enterprise" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "codexManaged" }, "requiresOpenaiAuth": false } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "awsManaged" }, "requiresOpenaiAuth": false } } ``` @@ -1948,6 +1949,7 @@ Response examples: Field notes: - `refreshToken` (bool): set `true` to force a token refresh. +- `email` is `null` when the ChatGPT account does not have an email address. - `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. - Amazon Bedrock reports `credentialSource: "codexManaged"` when it uses a Bedrock API key managed by Codex. Otherwise it reports `credentialSource: "awsManaged"` for the external AWS credential path. This identifies the selected credential source; it does not validate that the AWS credential chain can resolve credentials. diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 6e88866c7d4a..e49e841f65eb 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -5,7 +5,10 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use chrono::Duration; use chrono::Utc; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; @@ -14,6 +17,7 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -162,7 +166,7 @@ async fn get_auth_status_with_api_key() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { +async fn personal_access_token_without_email_supports_auth_status_and_account_read() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; @@ -171,7 +175,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> .and(path("/v1/user-auth-credential/whoami")) .and(header("Authorization", "Bearer at-test-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "email": "user@example.com", + "email": null, "chatgpt_user_id": "user-123", "chatgpt_account_id": "account-123", "chatgpt_plan_type": "pro", @@ -215,6 +219,34 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> } ); + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + response + .result + .get("account") + .and_then(|account| account.get("email")), + Some(&serde_json::Value::Null), + ); + assert_eq!( + to_response::(response)?, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + server.verify().await; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 06874c423014..3cd98e2e52d6 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -320,7 +320,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -388,7 +388,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1817,7 +1817,7 @@ async fn get_account_with_chatgpt() -> Result<()> { let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1826,6 +1826,51 @@ async fn get_account_with_chatgpt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn get_account_with_chatgpt_without_email() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(response)?; + + assert_eq!( + received, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + Ok(()) +} + #[tokio::test] async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> { let codex_home = TempDir::new()?; @@ -1944,7 +1989,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Unknown, }), requires_openai_auth: true, 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 d45dcdc3a5d6..86ea09504c65 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 @@ -204,12 +204,11 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> { "rate limit reset consume timed out" ); - let account_error = read_error_response(&mut mcp, account_id).await?; - assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - account_error.error.message, - "email and plan type are required for chatgpt authentication" - ); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(account_id)), + ) + .await??; Ok(()) } diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 47b49d43bb1a..dfd4fe50ced8 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -104,7 +104,7 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc { agent_private_key: key_material.private_key_pkcs8_base64, account_id: "account-12345".to_string(), chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Business, chatgpt_account_is_fedramp: false, task_id: Some("task-123".to_string()), diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index a164c7e3a503..79ee2e7f54b7 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -90,7 +90,7 @@ pub struct AgentIdentityAuth { pub(super) struct ManagedChatGptAgentIdentityBinding { pub(super) account_id: String, pub(super) chatgpt_user_id: String, - pub(super) email: String, + pub(super) email: Option, pub(super) plan_type: AccountPlanType, pub(super) chatgpt_account_is_fedramp: bool, pub(super) access_token: String, @@ -156,8 +156,8 @@ impl AgentIdentityAuth { &self.record.chatgpt_user_id } - pub fn email(&self) -> &str { - &self.record.email + pub fn email(&self) -> Option<&str> { + self.record.email.as_deref() } pub fn plan_type(&self) -> AccountPlanType { @@ -363,7 +363,7 @@ mod tests { agent_private_key: private_key, account_id: "account-1".to_string(), chatgpt_user_id: "user-1".to_string(), - email: "agent@example.com".to_string(), + email: Some("agent@example.com".to_string()), plan_type: AccountPlanType::Plus, chatgpt_account_is_fedramp: false, task_id: None, diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index fbb6aac8ea8a..bcce562e69e6 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -607,7 +607,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() )?; let mut record = agent_identity_record("account-123"); record.chatgpt_user_id = "user-12345".to_string(); - record.email = "user@example.com".to_string(); + record.email = Some("user@example.com".to_string()); let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let auth_path = get_auth_file(codex_home.path()); let mut auth_json = storage.try_read_auth_json(&auth_path)?; @@ -1926,7 +1926,7 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord { agent_private_key: key_material.private_key_pkcs8_base64, account_id: account_id.to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, task_id: None, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a762bd54668e..26580a672d52 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -506,8 +506,8 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account email. pub fn get_account_email(&self) -> Option { match self { - Self::AgentIdentity(auth) => Some(auth.email().to_string()), - Self::PersonalAccessToken(auth) => Some(auth.email().to_string()), + 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), } } @@ -723,7 +723,7 @@ impl ManagedChatGptAgentIdentityBinding { Some(Self { account_id, chatgpt_user_id, - email: token_data.id_token.email.clone().unwrap_or_default(), + email: token_data.id_token.email.clone(), plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown), chatgpt_account_is_fedramp: auth.is_fedramp_account(), access_token: token_data.access_token, diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index 5c9fcc3bdd2b..ea75648d1bbf 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -14,7 +14,7 @@ const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] struct PersonalAccessTokenMetadata { - email: String, + email: Option, chatgpt_user_id: String, chatgpt_account_id: String, chatgpt_plan_type: String, @@ -63,8 +63,8 @@ impl PersonalAccessTokenAuth { &self.metadata.chatgpt_user_id } - pub fn email(&self) -> &str { - &self.metadata.email + pub fn email(&self) -> Option<&str> { + self.metadata.email.as_deref() } pub fn plan_type(&self) -> AccountPlanType { diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs index 4d1b5e1ef756..b05edb068bf3 100644 --- a/codex-rs/login/src/auth/personal_access_token_tests.rs +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -40,7 +40,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { PersonalAccessTokenAuth { access_token: "at-example".to_string(), metadata: PersonalAccessTokenMetadata { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), chatgpt_user_id: "user-123".to_string(), chatgpt_account_id: "account-123".to_string(), chatgpt_plan_type: "enterprise".to_string(), @@ -52,7 +52,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { } #[tokio::test] -async fn hydrate_rejects_missing_email() { +async fn hydrate_preserves_missing_email() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path(WHOAMI_PATH)) @@ -62,13 +62,22 @@ async fn hydrate_rejects_missing_email() { .await; let endpoint = whoami_endpoint(&server.uri()); - let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") + let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await - .expect_err("personal access token hydration should reject missing email"); + .expect("personal access token hydration should accept missing email"); - assert!( - err.to_string() - .contains("failed to decode personal access token metadata") + assert_eq!( + auth, + PersonalAccessTokenAuth { + access_token: "at-example".to_string(), + metadata: PersonalAccessTokenMetadata { + email: None, + chatgpt_user_id: "user-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_plan_type: "enterprise".to_string(), + chatgpt_account_is_fedramp: true, + }, + } ); server.verify().await; } diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 19ba018f30e5..b5ae190551b1 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -92,13 +92,37 @@ pub struct AgentIdentityAuthRecord { pub agent_private_key: String, pub account_id: String, pub chatgpt_user_id: String, - pub email: String, + #[serde( + default, + deserialize_with = "deserialize_optional_non_empty_string", + serialize_with = "serialize_optional_string_as_empty" + )] + pub email: Option, pub plan_type: AccountPlanType, pub chatgpt_account_is_fedramp: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub task_id: Option, } +fn deserialize_optional_non_empty_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty())) +} + +fn serialize_optional_string_as_empty( + value: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + value.as_deref().unwrap_or_default().serialize(serializer) +} + impl AgentIdentityAuthRecord { pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result { let claims = diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index db51a7746e5a..647af79b656a 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -102,7 +102,7 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, task_id: Some("task-id".to_string()), @@ -124,6 +124,85 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn file_storage_loads_empty_agent_identity_email_as_none() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write( + &auth_file, + serde_json::to_string_pretty(&json!({ + "auth_mode": "chatgpt", + "agent_identity": { + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }, + }))?, + )?; + + let loaded = storage.load()?; + + assert_eq!( + loaded, + Some(AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: None, + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: None, + })), + personal_access_token: None, + bedrock_api_key: None, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn file_storage_writes_missing_agent_identity_email_as_empty_string() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: None, + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: None, + })), + personal_access_token: None, + bedrock_api_key: None, + }; + + storage.save(&auth_dot_json)?; + + let auth_file = get_auth_file(codex_home.path()); + let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(auth_file)?)?; + assert_eq!(saved["agent_identity"]["email"], ""); + assert_eq!(storage.load()?, Some(auth_dot_json)); + Ok(()) +} + #[tokio::test] async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 91a1c06a5792..3bd50313628f 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -60,10 +60,7 @@ impl fmt::Display for ProviderAccountError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingChatgptAccountDetails => { - write!( - f, - "email and plan type are required for chatgpt authentication" - ) + write!(f, "plan type is required for chatgpt authentication") } Self::UnsupportedBedrockApiKeyAuth => { write!( @@ -261,12 +258,9 @@ impl ModelProvider for ConfiguredModelProvider { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); - match (email, plan_type) { - (Some(email), Some(plan_type)) => { - Ok(ProviderAccount::Chatgpt { email, plan_type }) - } - _ => Err(ProviderAccountError::MissingChatgptAccountDetails), - } + plan_type + .map(|plan_type| ProviderAccount::Chatgpt { email, plan_type }) + .ok_or(ProviderAccountError::MissingChatgptAccountDetails) } }) .transpose()? @@ -313,6 +307,7 @@ mod tests { use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::WireApi; use codex_models_manager::manager::RefreshStrategy; + use codex_protocol::account::PlanType; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -518,7 +513,7 @@ mod tests { } #[test] - fn openai_provider_rejects_chatgpt_account_state_without_email() { + fn openai_provider_returns_chatgpt_account_state_without_email() { let provider = create_model_provider( ModelProviderInfo::create_openai_provider(/*base_url*/ None), Some(AuthManager::from_auth_for_testing( @@ -528,7 +523,13 @@ mod tests { assert_eq!( provider.account_state(), - Err(ProviderAccountError::MissingChatgptAccountDetails) + Ok(ProviderAccountState { + account: Some(ProviderAccount::Chatgpt { + email: None, + plan_type: PlanType::Unknown, + }), + requires_openai_auth: true, + }) ); } diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index 11286eddc885..dfcac51d30bc 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -35,7 +35,7 @@ pub enum PlanType { pub enum ProviderAccount { ApiKey, Chatgpt { - email: String, + email: Option, plan_type: PlanType, }, AmazonBedrock { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2a10f00b4232..6c0b83a72e98 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -148,6 +148,7 @@ arboard = { workspace = true } [dev-dependencies] +app_test_support = { workspace = true } codex-cli = { workspace = true } codex-mcp = { workspace = true } core_test_support = { workspace = true } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9ed78fed58d6..527cde57ed1f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -311,16 +311,19 @@ impl AppServerSession { false, ), Some(Account::Chatgpt { email, plan_type }) => { - let feedback_audience = if email.ends_with("@openai.com") { + let feedback_audience = if email + .as_deref() + .is_some_and(|email| email.ends_with("@openai.com")) + { FeedbackAudience::OpenAiEmployee } else { FeedbackAudience::External }; ( - Some(email.clone()), + email.clone(), Some(TelemetryAuthMode::Chatgpt), Some(StatusAccountDisplay::ChatGpt { - email: Some(email), + email, plan: Some(plan_type_display_name(plan_type)), }), Some(plan_type), diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap new file mode 100644 index 000000000000..4ac5239b945e --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ Account: Enterprise │ +│ │ +│ Limits: data not available yet │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 4ab7a2cfa228..50ef2c5000f3 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -17,6 +17,9 @@ use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::TimeZone; @@ -27,6 +30,7 @@ use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::test_support::construct_model_info_offline_for_tests; @@ -318,6 +322,67 @@ async fn status_snapshot_includes_reasoning_details() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_shows_chatgpt_plan_without_email() { + let temp_home = TempDir::new().expect("temp home"); + write_models_cache(temp_home.path()).expect("write models cache"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + write_chatgpt_auth( + temp_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("enterprise"), + AuthCredentialsStoreMode::File, + ) + .expect("write email-less ChatGPT auth"); + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("start embedded app server"); + let bootstrap = app_server + .bootstrap(&config) + .await + .expect("bootstrap app server session"); + app_server.shutdown().await.expect("shut down app server"); + let account_display = bootstrap + .status_account_display + .expect("bootstrap should return ChatGPT account display"); + assert_eq!( + account_display, + StatusAccountDisplay::ChatGpt { + email: None, + plan: Some("Enterprise".to_string()), + } + ); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + + let composite = new_status_output( + &config, + Some(&account_display), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let sanitized = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 80))).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_permissions_non_default_workspace_write_uses_workspace_label() { let temp_home = TempDir::new().expect("temp home"); diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index a4af5d605e1f..f3b97c6b6fde 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -503,6 +503,33 @@ def _annotate_schema(value: Any, base: str | None = None) -> None: _annotate_schema(child, base) +def _make_chatgpt_account_email_nullable(schema: dict[str, Any]) -> None: + definitions = schema.get("definitions") + if not isinstance(definitions, dict): + raise RuntimeError("Schema bundle is missing definitions") + + account = definitions.get("Account") + if not isinstance(account, dict): + raise RuntimeError("Schema bundle is missing the Account definition") + + for variant in account.get("oneOf", []): + if not isinstance(variant, dict): + continue + properties = variant.get("properties") + if not isinstance(properties, dict): + continue + account_type = properties.get("type") + if not isinstance(account_type, dict) or account_type.get("enum") != ["chatgpt"]: + continue + email = properties.get("email") + if not isinstance(email, dict): + raise RuntimeError("ChatGPT account schema is missing email") + email["type"] = ["string", "null"] + return + + raise RuntimeError("Schema bundle is missing the ChatGPT account variant") + + def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: """Generate app-server schemas by invoking the installed pinned runtime binary.""" codex_path = pinned_runtime_codex_path() @@ -525,6 +552,7 @@ def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: def _normalized_schema_bundle_text(schema_dir: Path) -> str: """Normalize the schema bundle before feeding it to the Python type generator.""" schema = json.loads(schema_bundle_path(schema_dir).read_text()) + _make_chatgpt_account_email_nullable(schema) definitions = schema.get("definitions", {}) if isinstance(definitions, dict): for definition in definitions.values(): @@ -580,9 +608,34 @@ def generate_v2_all(schema_dir: Path) -> None: ], cwd=sdk_root(), ) + _require_nullable_chatgpt_account_email(out_path) _normalize_generated_timestamps(out_path) +def _require_nullable_chatgpt_account_email(out_path: Path) -> None: + """Preserve required-but-nullable email semantics in the generated SDK model.""" + source = out_path.read_text() + class_start = source.find("class ChatgptAccount(BaseModel):") + if class_start == -1: + raise RuntimeError("Generated SDK is missing ChatgptAccount") + class_end = source.find("\n\nclass ", class_start) + if class_end == -1: + class_end = len(source) + + class_source = source[class_start:class_end] + nullable_with_default = " email: str | None = None" + if class_source.count(nullable_with_default) != 1: + raise RuntimeError( + "Generated ChatgptAccount email did not have the expected nullable shape" + ) + class_source = class_source.replace( + nullable_with_default, + " email: str | None", + 1, + ) + out_path.write_text(source[:class_start] + class_source + source[class_end:]) + + def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]: """Map each server notification method to its generated payload model class.""" server_notifications = json.loads((schema_dir / "ServerNotification.json").read_text()) diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 15ede1801cf6..c024a2c8c07c 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -4654,7 +4654,7 @@ class ChatgptAccount(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - email: str + email: str | None plan_type: Annotated[PlanType, Field(alias="planType")] type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")] diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 524260035740..b2cfda53b5e9 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -10,6 +10,7 @@ import pytest import tomllib +from pydantic import ValidationError ROOT = Path(__file__).resolve().parents[1] @@ -306,6 +307,32 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( ] +def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None: + script = _load_update_script_module() + schema = { + "definitions": { + "Account": { + "oneOf": [ + { + "properties": { + "email": {"type": "string"}, + "type": {"enum": ["chatgpt"], "type": "string"}, + }, + "required": ["email", "type"], + "type": "object", + } + ] + } + } + } + + script._make_chatgpt_account_email_nullable(schema) + + chatgpt_account = schema["definitions"]["Account"]["oneOf"][0] + assert chatgpt_account["properties"]["email"]["type"] == ["string", "null"] + assert "email" in chatgpt_account["required"] + + def test_python_codegen_schema_annotation_adds_stable_variant_titles( tmp_path: Path, ) -> None: @@ -350,6 +377,17 @@ def test_generate_v2_all_uses_titles_for_generated_names() -> None: assert "ruff-format" in source +def test_generated_chatgpt_account_email_is_required_nullable() -> None: + from openai_codex.generated.v2_all import ChatgptAccount + + account = ChatgptAccount.model_validate({"email": None, "planType": "pro", "type": "chatgpt"}) + assert account.email is None + assert ChatgptAccount.model_fields["email"].is_required() + + with pytest.raises(ValidationError): + ChatgptAccount.model_validate({"planType": "pro", "type": "chatgpt"}) + + def test_runtime_package_template_has_no_checked_in_binaries() -> None: runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin" assert sorted( From bd5bd953fb2a5d610a30d112eafe20b644924085 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 13:25:48 -0700 Subject: [PATCH 04/12] [codex] configure rollout budget reminder thresholds (#29423) ## Summary Instead of: reminder_interval_tokens = 65_536 allow users to configure explicit remaining-token reminder thresholds: reminder_at_remaining_tokens = [65_536, 32_768, 16_384, 8_192, 4_096, 2_048, 1_024, 512] ## Validation - CARGO_INCREMENTAL=0 just test -p codex-core rollout_budget: 9 passed - just fix -p codex-core - just fmt --- codex-rs/core/config.schema.json | 11 ++++--- codex-rs/core/src/config/config_tests.rs | 4 +-- codex-rs/core/src/config/mod.rs | 26 ++++++++++----- codex-rs/core/src/rollout_budget.rs | 14 +++++--- codex-rs/core/src/session/config_lock.rs | 4 +-- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/core/tests/suite/rollout_budget.rs | 36 +++++++++++---------- codex-rs/features/src/feature_configs.rs | 4 +-- 8 files changed, 60 insertions(+), 41 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b6736c940714..4615f4e8580c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2674,10 +2674,13 @@ "minimum": 0.0, "type": "number" }, - "reminder_interval_tokens": { - "format": "int64", - "minimum": 1.0, - "type": "integer" + "reminder_at_remaining_tokens": { + "description": "Remaining weighted-token values that trigger reminders when crossed.", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" }, "sampling_token_weight": { "format": "double", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index be332cd60392..e0dc04a48ec4 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -552,7 +552,7 @@ async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { [features.rollout_budget] enabled = true limit_tokens = 100000 -reminder_interval_tokens = 10000 +reminder_at_remaining_tokens = [50000, 25000, 10000] sampling_token_weight = 1.0 prefill_token_weight = 0.1 "#, @@ -571,7 +571,7 @@ prefill_token_weight = 0.1 config.rollout_budget, Some(RolloutBudgetConfig { limit_tokens: 100_000, - reminder_interval_tokens: 10_000, + reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, prefill_token_weight: 0.1, }) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 19d4bb44c1e0..371257d18526 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1098,10 +1098,10 @@ impl Default for TokenBudgetConfig { } } -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct RolloutBudgetConfig { pub limit_tokens: i64, - pub reminder_interval_tokens: i64, + pub reminder_at_remaining_tokens: Vec, pub sampling_token_weight: f64, pub prefill_token_weight: f64, } @@ -2606,13 +2606,23 @@ fn resolve_rollout_budget_config( "features.rollout_budget.limit_tokens must be positive", )); } - let reminder_interval_tokens = config - .reminder_interval_tokens - .unwrap_or_else(|| (limit_tokens / 10).max(1)); - if reminder_interval_tokens <= 0 { + let reminder_at_remaining_tokens = + config + .reminder_at_remaining_tokens + .clone() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.reminder_at_remaining_tokens is required when rollout_budget is enabled", + ) + })?; + if reminder_at_remaining_tokens + .iter() + .any(|&tokens| tokens <= 0 || tokens >= limit_tokens) + { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "features.rollout_budget.reminder_interval_tokens must be positive", + "features.rollout_budget.reminder_at_remaining_tokens must contain only positive values below limit_tokens", )); } let sampling_token_weight = config.sampling_token_weight.unwrap_or(1.0); @@ -2630,7 +2640,7 @@ fn resolve_rollout_budget_config( } Ok(Some(RolloutBudgetConfig { limit_tokens, - reminder_interval_tokens, + reminder_at_remaining_tokens, sampling_token_weight, prefill_token_weight, })) diff --git a/codex-rs/core/src/rollout_budget.rs b/codex-rs/core/src/rollout_budget.rs index b8901a9e36a3..334e9e73a585 100644 --- a/codex-rs/core/src/rollout_budget.rs +++ b/codex-rs/core/src/rollout_budget.rs @@ -57,18 +57,22 @@ impl RolloutBudget { window_id: &str, ) -> Option { let state = self.lock()?; - let reminder_index = (state.weighted_tokens_used - / state.config.reminder_interval_tokens as f64) + let remaining_tokens = (state.config.limit_tokens as f64 - state.weighted_tokens_used) + .max(0.0) .floor() as i64; + let reminder_index = state + .config + .reminder_at_remaining_tokens + .iter() + .filter(|&&threshold| remaining_tokens <= threshold) + .count() as i64; if state.deliveries.get(&thread_id).is_some_and(|delivery| { delivery.window_id.as_str() == window_id && delivery.reminder_index >= reminder_index }) { return None; } Some(RolloutBudgetReminder { - remaining_tokens: (state.config.limit_tokens as f64 - state.weighted_tokens_used) - .max(0.0) - .floor() as i64, + remaining_tokens, reminder_index, }) } diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index e91c4f489545..035f7bce4d84 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -255,7 +255,7 @@ mod tests { .expect("token_budget should be enableable in tests"); config.rollout_budget = Some(crate::config::RolloutBudgetConfig { limit_tokens: 100_000, - reminder_interval_tokens: 10_000, + reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, prefill_token_weight: 0.25, }); @@ -348,7 +348,7 @@ mod tests { Some(FeatureToml::Config(RolloutBudgetConfigToml { enabled: Some(true), limit_tokens: Some(100_000), - reminder_interval_tokens: Some(10_000), + reminder_at_remaining_tokens: Some(vec![50_000, 25_000, 10_000]), sampling_token_weight: Some(1.0), prefill_token_weight: Some(0.25), })) diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2cce3bb8a84f..deba2864b647 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1024,7 +1024,7 @@ impl ThreadManager { } fn agent_control_for_config(&self, config: &Config) -> AgentControl { - AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget) + AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget.clone()) } #[cfg(test)] diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 61c6dfe4240b..feafdc51efe9 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -25,12 +25,14 @@ use std::time::Duration; use test_case::test_case; use tokio::time::timeout; -const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig { - limit_tokens: 100, - reminder_interval_tokens: 25, - sampling_token_weight: 1.0, - prefill_token_weight: 1.0, -}; +fn rollout_budget() -> RolloutBudgetConfig { + RolloutBudgetConfig { + limit_tokens: 100, + reminder_at_remaining_tokens: vec![75, 50, 25], + sampling_token_weight: 1.0, + prefill_token_weight: 1.0, + } +} fn rollout_budget_texts(request: &ResponsesRequest) -> Vec { request @@ -51,7 +53,7 @@ fn wire_request_contains(request: &wiremock::Request, text: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> { +async fn adds_weighted_initial_and_threshold_reminders() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -83,7 +85,7 @@ async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> { config.rollout_budget = Some(RolloutBudgetConfig { sampling_token_weight: 2.0, prefill_token_weight: 0.5, - ..ROLLOUT_BUDGET + ..rollout_budget() }); }) .build(&server) @@ -171,7 +173,7 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { .features .enable(Feature::MultiAgentV2) .expect("test config should allow multi-agent v2"); - config.rollout_budget = Some(ROLLOUT_BUDGET); + config.rollout_budget = Some(rollout_budget()); }) .build(&server) .await?; @@ -218,8 +220,8 @@ async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { .with_config(|config| { config.rollout_budget = Some(RolloutBudgetConfig { limit_tokens: 30, - reminder_interval_tokens: 10, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![20, 10], + ..rollout_budget() }); }) .build(&server) @@ -293,8 +295,8 @@ async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: b config.model_provider = model_provider; config.rollout_budget = Some(RolloutBudgetConfig { limit_tokens: 10, - reminder_interval_tokens: 5, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![5], + ..rollout_budget() }); if remote_v2 { config @@ -354,8 +356,8 @@ async fn restates_the_current_remainder_after_compaction() -> Result<()> { .with_config(move |config| { config.model_provider = model_provider; config.rollout_budget = Some(RolloutBudgetConfig { - reminder_interval_tokens: 50, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() }); }) .build(&server) @@ -409,8 +411,8 @@ async fn restates_the_current_remainder_after_rollback() -> Result<()> { let test = test_codex() .with_config(|config| { config.rollout_budget = Some(RolloutBudgetConfig { - reminder_interval_tokens: 50, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() }); }) .build(&server) diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index a76aaaa3cec5..35b669b0a0fc 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -109,8 +109,8 @@ pub struct RolloutBudgetConfigToml { #[schemars(range(min = 1))] pub limit_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1))] - pub reminder_interval_tokens: Option, + /// Remaining weighted-token values that trigger reminders when crossed. + pub reminder_at_remaining_tokens: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0.0))] pub sampling_token_weight: Option, From ced3e4b9a7576fe08950965f2584d4d0c0d96937 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 22 Jun 2026 13:48:09 -0700 Subject: [PATCH 05/12] permission profiles: expose availability to clients (#26678) ## Why `permissionProfile/list` currently advertises every built-in and configured profile even when effective enterprise requirements prevent selecting it. That forces each client to reconstruct policy from lower-level requirement fields, which is easy to miss and difficult to keep consistent. The catalog should remain complete so clients can explain that an option was disabled by an administrator, while also reporting whether each profile is selectable. ## What - Add an `allowed` field to each permission profile summary. - Build a shared catalog from the effective config and current requirements, including `allowed_sandbox_modes`, `allowed_permissions`, and filesystem restrictions. - Use the shared catalog in app-server and the TUI so disallowed profiles remain visible but cannot be selected. - Use the canonical `:danger-full-access` profile ID in the TUI. - Update the app-server schemas, API documentation, behavioral tests, and TUI snapshots. ## Scope This PR targets `main` directly and is independent of #24852. It preserves the current behavior where built-in profiles are constrained by sandbox-mode requirements and `allowed_permissions` applies to configured profiles. ## Testing - `just test -p codex-core permission_profile_catalog_marks_profiles_disallowed_by_requirements` - `just test -p codex-app-server permission_profile_list` - `just test -p codex-app-server-protocol` - `just test -p codex-tui profile_permissions` - `just fix -p codex-core` - `just fix -p codex-app-server-protocol` - `just fix -p codex-app-server` - `just fix -p codex-tui` - `just fmt` --------- Co-authored-by: Codex Co-authored-by: Joey Trasatti --- .../codex_app_server_protocol.schemas.json | 5 + .../codex_app_server_protocol.v2.schemas.json | 5 + .../v2/PermissionProfileListResponse.json | 5 + .../typescript/v2/PermissionProfileSummary.ts | 6 +- .../src/protocol/v2/permissions.rs | 2 + codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 3 - .../request_processors/catalog_processor.rs | 32 +--- .../tests/suite/v2/permission_profile_list.rs | 13 ++ .../core/src/config/config_loader_tests.rs | 70 +++++++++ codex-rs/core/src/config/config_tests.rs | 3 +- codex-rs/core/src/config/mod.rs | 61 ++++---- .../src/config/permission_profile_catalog.rs | 140 ++++++++++++++++++ .../tui/src/chatwidget/permissions_menu.rs | 11 +- ..._selection_popup_with_custom_profiles.snap | 27 ++-- ...ion_popup_with_disallowed_full_access.snap | 20 +++ .../tui/src/chatwidget/tests/permissions.rs | 37 ++++- 17 files changed, 357 insertions(+), 85 deletions(-) create mode 100644 codex-rs/core/src/config/permission_profile_catalog.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap 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 f44e2f37ae93..b2be46f1f325 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 @@ -13045,6 +13045,10 @@ }, "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -13058,6 +13062,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" 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 5a8214b3a340..98b0d42976b5 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 @@ -9449,6 +9449,10 @@ }, "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -9462,6 +9466,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json index 4d5a47f8d5d6..1027b9c5a0d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json @@ -3,6 +3,10 @@ "definitions": { "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -16,6 +20,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts index 9d02fd776b4e..5796d30eed87 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts @@ -10,4 +10,8 @@ id: string, /** * Optional user-facing description for display in clients. */ -description: string | null, }; +description: string | null, +/** + * Whether the effective requirements allow selecting this profile. + */ +allowed: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 7434fe86535b..932e44e267ac 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -378,6 +378,8 @@ pub struct PermissionProfileSummary { pub id: String, /// Optional user-facing description for display in clients. pub description: Option, + /// Whether the effective requirements allow selecting this profile. + pub allowed: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 83eec5379c60..287b4f89ce18 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -202,7 +202,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. +- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2972d05f090e..35df59d5cc26 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -396,9 +396,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index f61d0ed4b6e8..95423402003d 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -1,5 +1,5 @@ use super::*; -use codex_config::config_toml::ConfigToml; +use codex_core::config::permission_profile_catalog; use futures::StreamExt; #[derive(Clone)] @@ -434,35 +434,15 @@ impl CatalogRequestProcessor { .await .map_err(|err| internal_error(format!("failed to reload config: {err}")))?, }; - let effective_config: ConfigToml = config_layer_stack - .effective_config() - .try_into() - .map_err(|err| internal_error(format!("failed to read effective config: {err}")))?; - let mut profiles = vec![ - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), - description: None, - }, - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), - description: None, - }, - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), - description: None, - }, - ]; - let mut configured_profiles = effective_config - .permissions + let profiles = permission_profile_catalog(&config_layer_stack) + .map_err(|err| internal_error(format!("failed to resolve permission profiles: {err}")))? .into_iter() - .flat_map(|permissions| permissions.entries) - .map(|(id, profile)| PermissionProfileSummary { - id, + .map(|profile| PermissionProfileSummary { + id: profile.id, description: profile.description, + allowed: profile.allowed, }) .collect::>(); - configured_profiles.sort_by(|left, right| left.id.cmp(&right.id)); - profiles.extend(configured_profiles); let total = profiles.len(); let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; let effective_limit = effective_limit.min(total); 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 63e92af57855..5d02cafa0a5c 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 @@ -60,22 +60,27 @@ description = "Inspect without writes." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: "audit".to_string(), description: Some("Inspect without writes.".to_string()), + allowed: true, }, PermissionProfileSummary { id: "dev".to_string(), description: Some("Day-to-day coding work.".to_string()), + allowed: true, }, ], next_cursor: None, @@ -126,14 +131,17 @@ description = "Project-scoped profile." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, ], next_cursor: Some("3".to_string()), @@ -155,6 +163,7 @@ description = "Project-scoped profile." data: vec![PermissionProfileSummary { id: "project".to_string(), description: Some("Project-scoped profile.".to_string()), + allowed: true, }], next_cursor: None, } @@ -200,18 +209,22 @@ description = "Project-scoped profile." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: "project".to_string(), description: Some("Project-scoped profile.".to_string()), + allowed: true, }, ], next_cursor: None, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index c01c555519ad..1a9245b1aa8e 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1,6 +1,8 @@ use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConstraintError; +use crate::config::PermissionProfileCatalogEntry; +use crate::config::permission_profile_catalog; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; use codex_config::CloudConfigBundleLoadError; @@ -1733,6 +1735,74 @@ managed-standard = true Ok(()) } +#[tokio::test] +async fn permission_profile_catalog_marks_profiles_disallowed_by_requirements() -> anyhow::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_sandbox_modes = ["read-only", "workspace-write"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard] +extends = ":workspace" + +[permissions.managed-disabled] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + permission_profile_catalog(&config.config_layer_stack)?, + vec![ + PermissionProfileCatalogEntry { + id: ":read-only".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: ":workspace".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: ":danger-full-access".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: "managed-disabled".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: "managed-standard".to_string(), + description: None, + allowed: true, + }, + ] + ); + Ok(()) +} + #[tokio::test] async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e0dc04a48ec4..2d27d8d603bc 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3192,9 +3192,10 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( config.custom_permission_profiles, - vec![CustomPermissionProfileSummary { + vec![PermissionProfileCatalogEntry { id: "dev".to_string(), description: Some("Workspace access.".to_string()), + allowed: true, }] ); assert!( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 371257d18526..95807c7884bd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -148,6 +148,7 @@ pub mod edit; mod managed_features; mod network_proxy_spec; mod otel; +mod permission_profile_catalog; mod permissions; mod resolved_permission_profile; #[cfg(test)] @@ -164,6 +165,11 @@ pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub use permission_profile_catalog::PermissionProfileCatalogEntry; +pub use permission_profile_catalog::permission_profile_catalog; +use permission_profile_catalog::permission_profile_catalog_from_permissions; +use permission_profile_catalog::permission_profile_is_allowed; +use permission_profile_catalog::validate_permission_profile_for_deny_read; pub(crate) use permissions::is_builtin_permission_profile_name; pub(crate) use permissions::reject_unknown_builtin_permission_profile; pub(crate) use permissions::resolve_permission_profile; @@ -645,7 +651,7 @@ pub struct Config { pub explicit_permission_profile_mode: bool, /// User-defined permission profiles available from effective config. - pub custom_permission_profiles: Vec, + pub custom_permission_profiles: Vec, /// Configures who approval requests are routed to for review once they have /// been escalated. This does not disable separate safety checks such as @@ -2150,12 +2156,6 @@ pub struct AgentRoleConfig { pub nickname_candidates: Option>, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CustomPermissionProfileSummary { - pub id: String, - pub description: Option, -} - fn resolve_tool_suggest_config( config_toml: &ConfigToml, config_layer_stack: &ConfigLayerStack, @@ -3132,19 +3132,13 @@ impl Config { permission_config_syntax, Some(PermissionConfigSyntax::Profiles) ); - let custom_permission_profiles = cfg - .permissions - .as_ref() - .map_or_else(Vec::new, |permissions| { - permissions - .entries - .iter() - .map(|(id, profile)| CustomPermissionProfileSummary { - id: id.clone(), - description: profile.description.clone(), - }) - .collect() - }); + let custom_permission_profiles = permission_profile_catalog_from_permissions( + &config_layer_stack, + effective_permission_selection.profiles.as_ref(), + )? + .into_iter() + .filter(|profile| !is_builtin_permission_profile_name(&profile.id)) + .collect(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && effective_permission_selection.selected_profile_id.is_none(); let should_seed_legacy_workspace_roots = effective_permission_selection @@ -3656,20 +3650,10 @@ impl Config { constrained_permission_profile .value .add_validator(move |permission_profile| { - let mode = sandbox_mode_requirement_for_permission_profile(permission_profile); - match mode { - SandboxModeRequirement::ReadOnly - | SandboxModeRequirement::WorkspaceWrite => Ok(()), - SandboxModeRequirement::DangerFullAccess - | SandboxModeRequirement::ExternalSandbox => { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: format!("{mode:?}"), - allowed: "[read-only, workspace-write]".to_string(), - requirement_source: requirement_source.clone(), - }) - } - } + validate_permission_profile_for_deny_read( + permission_profile, + &requirement_source, + ) }) .map_err(std::io::Error::from)?; } @@ -4122,6 +4106,15 @@ impl Config { pub fn bundled_skills_enabled(&self) -> bool { crate::skills::service::bundled_skills_enabled_from_stack(&self.config_layer_stack) } + + /// Returns whether effective requirements allow selecting a concrete profile. + pub fn permission_profile_allowed( + &self, + profile_id: &str, + permission_profile: &PermissionProfile, + ) -> bool { + permission_profile_is_allowed(&self.config_layer_stack, profile_id, permission_profile) + } } fn guardian_policy_config_from_requirements( diff --git a/codex-rs/core/src/config/permission_profile_catalog.rs b/codex-rs/core/src/config/permission_profile_catalog.rs new file mode 100644 index 000000000000..51b34c26406f --- /dev/null +++ b/codex-rs/core/src/config/permission_profile_catalog.rs @@ -0,0 +1,140 @@ +use codex_config::ConfigLayerStack; +use codex_config::RequirementSource; +use codex_config::SandboxModeRequirement; +use codex_config::Sourced; +use codex_config::permissions_toml::PermissionsToml; +use codex_config::sandbox_mode_requirement_for_permission_profile; +use codex_protocol::models::PermissionProfile; + +use super::ConstraintError; +use super::ConstraintResult; +use super::is_permission_allowed; +use super::merge_managed_permission_profiles; +use super::permissions::BUILT_IN_DANGER_FULL_ACCESS_PROFILE; +use super::permissions::BUILT_IN_READ_ONLY_PROFILE; +use super::permissions::BUILT_IN_WORKSPACE_PROFILE; +use super::permissions::compile_permission_profile_selection; +use super::permissions::validate_user_permission_profile_names; +use super::validate_required_permission_profile_catalog; + +/// A permission profile exposed to clients together with its effective availability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileCatalogEntry { + pub id: String, + pub description: Option, + pub allowed: bool, +} + +/// Builds the effective permission profile catalog for a config layer stack. +pub fn permission_profile_catalog( + config_layer_stack: &ConfigLayerStack, +) -> std::io::Result> { + let permissions = config_layer_stack + .effective_config() + .get("permissions") + .cloned() + .map(toml::Value::try_into::) + .transpose() + .map_err(std::io::Error::other)?; + let requirements_toml = config_layer_stack.requirements_toml(); + let permissions = merge_managed_permission_profiles(permissions.as_ref(), requirements_toml)?; + + permission_profile_catalog_from_permissions(config_layer_stack, permissions.as_ref()) +} + +pub(super) fn permission_profile_catalog_from_permissions( + config_layer_stack: &ConfigLayerStack, + permissions: Option<&PermissionsToml>, +) -> std::io::Result> { + let requirements_toml = config_layer_stack.requirements_toml(); + validate_user_permission_profile_names(permissions)?; + validate_required_permission_profile_catalog(requirements_toml, permissions)?; + + let mut catalog = [ + (BUILT_IN_READ_ONLY_PROFILE, PermissionProfile::read_only()), + ( + BUILT_IN_WORKSPACE_PROFILE, + PermissionProfile::workspace_write(), + ), + ( + BUILT_IN_DANGER_FULL_ACCESS_PROFILE, + PermissionProfile::Disabled, + ), + ] + .into_iter() + .map(|(id, permission_profile)| PermissionProfileCatalogEntry { + id: id.to_string(), + description: None, + allowed: permission_profile_is_allowed(config_layer_stack, id, &permission_profile), + }) + .collect::>(); + + if let Some(permissions) = permissions { + catalog.extend(permissions.entries.iter().map(|(id, profile)| { + let mut warnings = Vec::new(); + let allowed = compile_permission_profile_selection( + Some(permissions), + id, + /*workspace_write*/ None, + &mut warnings, + ) + .map(|(file_system, network)| { + PermissionProfile::from_runtime_permissions(&file_system, network) + }) + .is_ok_and(|permission_profile| { + permission_profile_is_allowed(config_layer_stack, id, &permission_profile) + }); + PermissionProfileCatalogEntry { + id: id.clone(), + description: profile.description.clone(), + allowed, + } + })); + } + + Ok(catalog) +} + +pub(super) fn permission_profile_is_allowed( + config_layer_stack: &ConfigLayerStack, + profile_id: &str, + permission_profile: &PermissionProfile, +) -> bool { + let allowed_by_id = config_layer_stack + .requirements_toml() + .allowed_permission_profiles + .as_ref() + .is_none_or(|allowed| is_permission_allowed(allowed, profile_id)); + let allowed_by_sandbox_mode = config_layer_stack + .requirements() + .permission_profile + .can_set(permission_profile) + .is_ok(); + let allowed_by_filesystem = config_layer_stack + .requirements() + .filesystem + .as_ref() + .is_none_or(|Sourced { value, source }| { + value.deny_read.is_empty() + || validate_permission_profile_for_deny_read(permission_profile, source).is_ok() + }); + allowed_by_id && allowed_by_sandbox_mode && allowed_by_filesystem +} + +pub(super) fn validate_permission_profile_for_deny_read( + permission_profile: &PermissionProfile, + requirement_source: &RequirementSource, +) -> ConstraintResult<()> { + let mode = sandbox_mode_requirement_for_permission_profile(permission_profile); + match mode { + SandboxModeRequirement::ReadOnly | SandboxModeRequirement::WorkspaceWrite => Ok(()), + SandboxModeRequirement::DangerFullAccess | SandboxModeRequirement::ExternalSandbox => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: requirement_source.clone(), + }) + } + } +} diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index c9cb3b108fb3..e26e8deccc09 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -48,7 +48,7 @@ impl ChatWidget { } items.push(self.builtin_permission_mode_selection_item( full_access, - ":danger-no-sandbox", + ":danger-full-access", full_access.description.to_string(), AskForApproval::from(full_access.approval), ApprovalsReviewer::User, @@ -73,6 +73,7 @@ impl ChatWidget { .as_deref() .unwrap_or("Configured permission profile."), active_profile_id.as_deref(), + profile.allowed, ) }), ); @@ -141,6 +142,12 @@ impl ChatWidget { .can_set_permission_profile(&preset.permission_profile) .err() .map(|err| err.to_string()) + }) + .or_else(|| { + (!self + .config + .permission_profile_allowed(id, &preset.permission_profile)) + .then(|| "Disabled by requirements.".to_string()) }), ..Default::default() } @@ -151,6 +158,7 @@ impl ChatWidget { id: &str, description: &str, active_profile_id: Option<&str>, + allowed: bool, ) -> SelectionItem { let id_for_action = id.to_string(); let selection = PermissionProfileSelection { @@ -165,6 +173,7 @@ impl ChatWidget { is_current: active_profile_id == Some(id), actions: Self::permission_profile_selection_actions(selection), dismiss_on_select: true, + disabled_reason: (!allowed).then(|| "Disabled by requirements.".to_string()), ..Default::default() } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap index dac63d4e3248..008669d3d3a5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap @@ -4,18 +4,19 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions - 1. Ask for approval Codex can read and edit files in the current - workspace, and run commands. Approval is required - to access the internet or edit other files. - 2. Approve for me Only ask for actions detected as potentially - unsafe. - 3. Full Access Codex can edit files outside this workspace and - access the internet without asking for approval. - Exercise caution when using. - 4. Read Only Codex can read files in the current workspace. - Approval is required to edit files or access the - internet. -› 5. locked-down (current) Inspect and patch only approved workspace files. - 6. web-enabled Workspace profile with network access. + 1. Ask for approval Codex can read and edit files in the current + workspace, and run commands. Approval is required + to access the internet or edit other files. + 2. Approve for me Only ask for actions detected as potentially + unsafe. + 3. Full Access Codex can edit files outside this workspace and + access the internet without asking for approval. + Exercise caution when using. + 4. Read Only Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. +› 5. locked-down (current) Inspect and patch only approved workspace files. + web-enabled (disabled) Workspace profile with network access. (disabled: + Disabled by requirements.) Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap new file mode 100644 index 000000000000..1d2a74e9565d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Update Model Permissions + + 1. Ask for approval Codex can read and edit files in the current + workspace, and run commands. Approval is required + to access the internet or edit other files. + 2. Approve for me Only ask for actions detected as potentially + unsafe. + Full Access (disabled) Codex can edit files outside this workspace and + access the internet without asking for approval. + Exercise caution when using. (disabled: Disabled + by requirements.) +› 3. Read Only (current) Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a9f6f7abe114..849da3bacfb8 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,5 +1,5 @@ use super::*; -use crate::legacy_core::config::CustomPermissionProfileSummary; +use crate::legacy_core::config::PermissionProfileCatalogEntry; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; @@ -57,6 +57,10 @@ fn windows_sandbox_requirements_stack( }), ..Default::default() }; + requirements_stack(requirements_toml) +} + +fn requirements_stack(requirements_toml: codex_config::ConfigRequirementsToml) -> ConfigLayerStack { let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default(); requirements_with_sources .merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone()); @@ -104,18 +108,40 @@ async fn profile_permissions_selection_popup_snapshot() { ); } +#[tokio::test] +async fn profile_permissions_selection_popup_with_disallowed_full_access_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.explicit_permission_profile_mode = true; + chat.config.config_layer_stack = requirements_stack(codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + codex_config::SandboxModeRequirement::ReadOnly, + codex_config::SandboxModeRequirement::WorkspaceWrite, + ]), + ..Default::default() + }); + + chat.open_permissions_popup(); + + assert_chatwidget_snapshot!( + "profile_permissions_selection_popup_with_disallowed_full_access", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + #[tokio::test] async fn profile_permissions_selection_popup_with_custom_profiles_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.explicit_permission_profile_mode = true; chat.config.custom_permission_profiles = vec![ - CustomPermissionProfileSummary { + PermissionProfileCatalogEntry { id: "locked-down".to_string(), description: Some("Inspect and patch only approved workspace files.".to_string()), + allowed: true, }, - CustomPermissionProfileSummary { + PermissionProfileCatalogEntry { id: "web-enabled".to_string(), description: Some("Workspace profile with network access.".to_string()), + allowed: false, }, ]; chat.config @@ -170,9 +196,10 @@ async fn profile_permissions_selection_emits_named_profile_event_only() { async fn profile_permissions_selection_emits_active_custom_profile() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.explicit_permission_profile_mode = true; - chat.config.custom_permission_profiles = vec![CustomPermissionProfileSummary { + chat.config.custom_permission_profiles = vec![PermissionProfileCatalogEntry { id: "locked-down".to_string(), description: None, + allowed: true, }]; chat.config .permissions @@ -256,7 +283,7 @@ async fn profile_permissions_full_access_opens_confirmation() { display_label, }), } if preset.id == "full-access" - && profile_id == ":danger-no-sandbox" + && profile_id == ":danger-full-access" && display_label == "Full Access" )); } From cb255c52e93bab74d35be7a093d0770a8dd66772 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 22 Jun 2026 13:55:32 -0700 Subject: [PATCH 06/12] [codex] handle request_user_input in app-server test client (#29476) ## Why `codex-app-server-test-client` previously treated `item/tool/requestUserInput` as an unsupported server request and terminated the connection. That made it impossible to use the client for end-to-end testing of interactive turns: an operator could observe the request, but could not answer it and confirm that the same turn resumed. ## What changed - Handle `ToolRequestUserInput` server requests in the test client's central request dispatcher. - Render numbered terminal choices, accept exact option labels, support free-form `Other` and text-only questions, and collect multiple answers. - Send a protocol-native `ToolRequestUserInputResponse` and continue streaming the active turn. - Fail clearly when interactive input is requested without a terminal. - Document the interactive behavior and add focused tests for option selection, free-form answers, multiple questions, and invalid-selection retries. ## Testing - `just test -p codex-app-server-test-client` - `just bazel-lock-check` - Manually exercised the app-server flow, selected `TUI`, observed `serverRequest/resolved`, and verified that the same turn completed with the selected answer. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-test-client/Cargo.toml | 3 + codex-rs/app-server-test-client/README.md | 4 + codex-rs/app-server-test-client/src/lib.rs | 5 + .../src/request_user_input.rs | 148 ++++++++++++++++++ .../src/request_user_input_tests.rs | 125 +++++++++++++++ 6 files changed, 286 insertions(+) create mode 100644 codex-rs/app-server-test-client/src/request_user_input.rs create mode 100644 codex-rs/app-server-test-client/src/request_user_input_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ae50cb5c853d..9cbd3f31a3e1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2136,6 +2136,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-cli", + "pretty_assertions", "serde", "serde_json", "tokio", diff --git a/codex-rs/app-server-test-client/Cargo.toml b/codex-rs/app-server-test-client/Cargo.toml index 631b40207f6b..901afb70d00f 100644 --- a/codex-rs/app-server-test-client/Cargo.toml +++ b/codex-rs/app-server-test-client/Cargo.toml @@ -26,3 +26,6 @@ uuid = { workspace = true, features = ["v4"] } [lib] doctest = false + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/app-server-test-client/README.md b/codex-rs/app-server-test-client/README.md index 5499706b9383..44687672a010 100644 --- a/codex-rs/app-server-test-client/README.md +++ b/codex-rs/app-server-test-client/README.md @@ -18,6 +18,10 @@ cargo run -p codex-app-server-test-client -- \ cargo run -p codex-app-server-test-client -- model-list ``` +`send-message` and `send-message-v2` handle `request_user_input` server requests interactively. +When Codex asks a question, choose a numbered option (or `o` for a free-form answer when offered) +and the client will send the response and continue streaming the same turn. + ## Testing Plugin Analytics The `plugin-analytics-smoke` command exercises `plugin/installed`, plugin diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 856d75585ec3..8e4630857543 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -91,6 +91,7 @@ mod loopback_responses_server; mod plugin_analytics_capture; mod plugin_analytics_mutation_smoke; mod plugin_analytics_smoke; +mod request_user_input; const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ // v2 item deltas. @@ -2040,6 +2041,10 @@ impl CodexClient { ServerRequest::FileChangeRequestApproval { request_id, params } => { self.approve_file_change_request(request_id, params)?; } + ServerRequest::ToolRequestUserInput { request_id, params } => { + let response = request_user_input::prompt_for_answers(¶ms)?; + self.send_server_request_response(request_id, &response)?; + } other => { bail!("received unsupported server request: {other:?}"); } diff --git a/codex-rs/app-server-test-client/src/request_user_input.rs b/codex-rs/app-server-test-client/src/request_user_input.rs new file mode 100644 index 000000000000..0302f336003b --- /dev/null +++ b/codex-rs/app-server-test-client/src/request_user_input.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use std::io; +use std::io::BufRead; +use std::io::IsTerminal; +use std::io::Write; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use codex_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputResponse; + +pub(super) fn prompt_for_answers( + params: &ToolRequestUserInputParams, +) -> Result { + let stdin = io::stdin(); + if !stdin.is_terminal() { + bail!("request_user_input requires an interactive stdin terminal"); + } + + let stdout = io::stdout(); + prompt_for_answers_with(&mut stdin.lock(), &mut stdout.lock(), params) +} + +fn prompt_for_answers_with( + input: &mut impl BufRead, + output: &mut impl Write, + params: &ToolRequestUserInputParams, +) -> Result { + writeln!( + output, + "\n[request_user_input for thread {}, turn {}]", + params.thread_id, params.turn_id + )?; + if let Some(auto_resolution_ms) = params.auto_resolution_ms { + writeln!( + output, + "The app-server may auto-resolve this request after {auto_resolution_ms} ms." + )?; + } + + let mut answers = HashMap::new(); + for question in ¶ms.questions { + writeln!(output, "\n{}: {}", question.header, question.question)?; + let options = question + .options + .as_deref() + .filter(|options| !options.is_empty()); + let answer_values = if let Some(options) = options { + for (index, option) in options.iter().enumerate() { + writeln!( + output, + " {}. {} - {}", + index + 1, + option.label, + option.description + )?; + } + if question.is_other { + writeln!(output, " o. Other (free-form)")?; + } + + loop { + if question.is_other { + write!(output, "Choose 1-{} or o: ", options.len())?; + } else { + write!(output, "Choose 1-{}: ", options.len())?; + } + output.flush()?; + + let mut line = String::new(); + if input + .read_line(&mut line) + .context("failed to read request_user_input selection")? + == 0 + { + bail!("stdin closed while waiting for request_user_input selection"); + } + let selection = line.trim(); + + if let Ok(index) = selection.parse::() + && let Some(option) = index.checked_sub(1).and_then(|index| options.get(index)) + { + break vec![option.label.clone()]; + } + + if let Some(option) = options + .iter() + .find(|option| option.label.eq_ignore_ascii_case(selection)) + { + break vec![option.label.clone()]; + } + + if question.is_other && selection.eq_ignore_ascii_case("o") { + write!(output, "Other: ")?; + output.flush()?; + line.clear(); + if input + .read_line(&mut line) + .context("failed to read request_user_input free-form answer")? + == 0 + { + bail!("stdin closed while waiting for request_user_input free-form answer"); + } + let answer = line.trim(); + if !answer.is_empty() { + break vec![format!("user_note: {answer}")]; + } + } + + writeln!(output, "Invalid selection; try again.")?; + } + } else { + loop { + write!(output, "Answer: ")?; + output.flush()?; + + let mut line = String::new(); + if input + .read_line(&mut line) + .context("failed to read request_user_input answer")? + == 0 + { + bail!("stdin closed while waiting for request_user_input answer"); + } + let answer = line.trim(); + if !answer.is_empty() { + break vec![format!("user_note: {answer}")]; + } + writeln!(output, "Answer cannot be empty; try again.")?; + } + }; + + answers.insert( + question.id.clone(), + ToolRequestUserInputAnswer { + answers: answer_values, + }, + ); + } + + Ok(ToolRequestUserInputResponse { answers }) +} + +#[cfg(test)] +#[path = "request_user_input_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-test-client/src/request_user_input_tests.rs b/codex-rs/app-server-test-client/src/request_user_input_tests.rs new file mode 100644 index 000000000000..d8b2a4699d7d --- /dev/null +++ b/codex-rs/app-server-test-client/src/request_user_input_tests.rs @@ -0,0 +1,125 @@ +use std::collections::HashMap; +use std::io::Cursor; + +use codex_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use pretty_assertions::assert_eq; + +use super::prompt_for_answers_with; + +#[test] +fn collects_option_and_free_form_answers() { + let params = ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + questions: vec![ + ToolRequestUserInputQuestion { + id: "target".to_string(), + header: "Target".to_string(), + question: "Which target?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Core".to_string(), + description: "Inspect core".to_string(), + }, + ToolRequestUserInputOption { + label: "TUI".to_string(), + description: "Inspect TUI".to_string(), + }, + ]), + }, + ToolRequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "Anything else?".to_string(), + is_other: true, + is_secret: false, + options: None, + }, + ], + auto_resolution_ms: Some(60_000), + }; + let mut input = Cursor::new(b"2\ninclude snapshots\n"); + let mut output = Vec::new(); + + let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap(); + + assert_eq!( + response, + ToolRequestUserInputResponse { + answers: HashMap::from([ + ( + "target".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["TUI".to_string()], + }, + ), + ( + "details".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["user_note: include snapshots".to_string()], + }, + ), + ]), + } + ); + assert_eq!( + String::from_utf8(output).unwrap(), + concat!( + "\n[request_user_input for thread thread-1, turn turn-1]\n", + "The app-server may auto-resolve this request after 60000 ms.\n", + "\nTarget: Which target?\n", + " 1. Core - Inspect core\n", + " 2. TUI - Inspect TUI\n", + " o. Other (free-form)\n", + "Choose 1-2 or o: ", + "\nDetails: Anything else?\n", + "Answer: ", + ) + ); +} + +#[test] +fn retries_invalid_selection_and_collects_other_answer() { + let params = ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "target".to_string(), + header: "Target".to_string(), + question: "Which target?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "Core".to_string(), + description: "Inspect core".to_string(), + }]), + }], + auto_resolution_ms: None, + }; + let mut input = Cursor::new(b"9\no\nSDK wrapper\n"); + let mut output = Vec::new(); + + let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap(); + + assert_eq!( + response, + ToolRequestUserInputResponse { + answers: HashMap::from([( + "target".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["user_note: SDK wrapper".to_string()], + }, + )]), + } + ); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Invalid selection; try again.")); +} From 6db576895a808608d65578f9d704971601c23c9b Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 22 Jun 2026 14:05:50 -0700 Subject: [PATCH 07/12] fix(config): address permission profile review follow-ups (#29479) ## Summary - rename `Config::permission_profile_allowed` to `is_permission_profile_allowed` - use `BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS` in the TUI and its assertion - follow up on the late review comments from #26678 The previous `:danger-no-sandbox` value was an invalid built-in profile ID. #26678 corrected it to `:danger-full-access`; this PR centralizes the value to prevent future drift. ## Testing - Not run per request; `cargo fmt` only Co-authored-by: Codex --- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/tui/src/chatwidget/permissions_menu.rs | 5 +++-- codex-rs/tui/src/chatwidget/tests/permissions.rs | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 95807c7884bd..953d0af1a187 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -4108,7 +4108,7 @@ impl Config { } /// Returns whether effective requirements allow selecting a concrete profile. - pub fn permission_profile_allowed( + pub fn is_permission_profile_allowed( &self, profile_id: &str, permission_profile: &PermissionProfile, diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index e26e8deccc09..c97721819ebb 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; impl ChatWidget { pub(super) fn open_permission_profiles_popup(&mut self) { @@ -48,7 +49,7 @@ impl ChatWidget { } items.push(self.builtin_permission_mode_selection_item( full_access, - ":danger-full-access", + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, full_access.description.to_string(), AskForApproval::from(full_access.approval), ApprovalsReviewer::User, @@ -146,7 +147,7 @@ impl ChatWidget { .or_else(|| { (!self .config - .permission_profile_allowed(id, &preset.permission_profile)) + .is_permission_profile_allowed(id, &preset.permission_profile)) .then(|| "Disabled by requirements.".to_string()) }), ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 849da3bacfb8..ebac59567148 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,6 +1,7 @@ use super::*; use crate::legacy_core::config::PermissionProfileCatalogEntry; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -283,7 +284,7 @@ async fn profile_permissions_full_access_opens_confirmation() { display_label, }), } if preset.id == "full-access" - && profile_id == ":danger-full-access" + && profile_id == BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS && display_label == "Full Access" )); } From 527ccb4a5df8a75d6aeb4c6ae4341f99a1205a1e Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Mon, 22 Jun 2026 14:16:48 -0700 Subject: [PATCH 08/12] Honor startup custom CA bundles with managed MITM (#29014) ## Why When Codex starts with a custom CA override such as `SSL_CERT_FILE=/path/to/corp-ca.pem codex`, `rustls-native-certs` treats that override as a replacement for the platform trust store. The managed proxy then rewrites child CA variables to its generated bundle, so the custom root or the ordinary platform roots can be lost. The proxy's upstream TLS connector must trust the same roots or private and corporate upstream certificates still fail after interception. ## What - load platform-native roots without consulting inherited CA override variables - append certificates from the existing curated startup CA file variables and `SSL_CERT_DIR` - share those platform and startup roots with the MITM upstream rustls connector - exclude the Codex managed MITM CA from upstream trust - normalize OpenSSL `TRUSTED CERTIFICATE` blocks while dropping trailing trust metadata - skip an inherited current Codex-managed bundle so nested launches do not duplicate it - append the Codex managed MITM CA to the child-facing bundle - copy certificate material only, so a private key or unrelated text colocated in a startup file is never exposed through the public bundle This is intentionally limited to CA paths present when Codex starts. It does not parse inline shell assignments or add per-command bundle materialization. This changes only `codex-network-proxy` and dependency metadata; it does not touch `codex-core` or sandbox orchestration. ## Validation - `just test -p codex-network-proxy` - includes an end-to-end upstream TLS test using a server trusted only by the startup custom CA - `just fix -p codex-network-proxy` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 2 + codex-rs/network-proxy/Cargo.toml | 6 + codex-rs/network-proxy/src/certs.rs | 275 +++++++++++++++++-- codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/mitm.rs | 12 +- codex-rs/network-proxy/src/native_certs.rs | 260 ++++++++++++++++++ codex-rs/network-proxy/src/proxy.rs | 5 +- codex-rs/network-proxy/src/upstream.rs | 34 ++- codex-rs/network-proxy/src/upstream_tests.rs | 111 ++++++++ 9 files changed, 677 insertions(+), 29 deletions(-) create mode 100644 codex-rs/network-proxy/src/native_certs.rs create mode 100644 codex-rs/network-proxy/src/upstream_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9cbd3f31a3e1..03ed82f1bf91 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3530,6 +3530,8 @@ dependencies = [ "rama-tls-rustls", "rama-unix", "rustls-native-certs", + "schannel", + "security-framework 3.5.1", "serde", "serde_json", "sha2 0.10.9", diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index cd32ff92b22e..cc6a8de7104f 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -44,3 +44,9 @@ tempfile = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] rama-unix = { version = "=0.3.0-alpha.4" } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +[target.'cfg(windows)'.dependencies] +schannel = "0.1" diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 001469aa9a65..2ff7cc03b459 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,6 +23,7 @@ use rama_tls_rustls::server::TlsAcceptorData; use sha2::Digest as _; use sha2::Sha256; use std::collections::HashMap; +use std::collections::HashSet; use std::fs; use std::fs::File; use std::fs::OpenOptions; @@ -30,6 +31,7 @@ use std::io::Write; use std::net::IpAddr; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tracing::info; @@ -101,6 +103,7 @@ const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; +pub(crate) const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; // Best-effort compatibility set for common child toolchains that accept a CA bundle path. // This is intentionally curated rather than pretending to cover every TLS client. @@ -117,6 +120,14 @@ pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ "NPM_CONFIG_CAFILE", ]; +pub(crate) fn ca_env_from_process() -> HashMap<&'static str, String> { + CUSTOM_CA_ENV_KEYS + .into_iter() + .chain([SSL_CERT_DIR_ENV_KEY]) + .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) + .collect() +} + /// Immutable managed MITM CA bundle path plus startup TLS env values. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ManagedMitmCaTrustBundle { @@ -146,38 +157,203 @@ fn managed_ca_trust_bundle_for_cert_path( cert_path: &Path, env: &HashMap<&'static str, String>, ) -> Result { - let startup_env_values = CUSTOM_CA_ENV_KEYS + let startup_env_values = startup_ca_file_env_values(env); + let startup_cert_dir = env + .get(SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty()) + .map(String::as_str); + let trust_bundle = + build_managed_ca_trust_bundle(cert_path, &startup_env_values, startup_cert_dir)?; + let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?; + + Ok(ManagedMitmCaTrustBundle { + path, + startup_env_values, + }) +} + +pub(crate) fn upstream_tls_root_store( + env: &HashMap<&'static str, String>, +) -> Result> { + let (managed_ca_cert_path, _) = managed_ca_paths()?; + upstream_tls_root_store_for_cert_path(&managed_ca_cert_path, env) +} + +pub(crate) fn upstream_tls_root_store_for_cert_path( + managed_ca_cert_path: &Path, + env: &HashMap<&'static str, String>, +) -> Result> { + let startup_env_values = startup_ca_file_env_values(env); + let startup_cert_dir = env + .get(SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty()) + .map(String::as_str); + let certificates = load_platform_and_startup_root_certificates( + managed_ca_cert_path, + &startup_env_values, + startup_cert_dir, + )?; + let mut roots = rustls::RootCertStore::empty(); + let (_, ignored) = roots.add_parsable_certificates(certificates); + if ignored > 0 { + warn!( + ignored_root_count = ignored, + "ignored invalid platform or startup roots for MITM upstream TLS" + ); + } + Ok(Arc::new(roots)) +} + +fn startup_ca_file_env_values( + env: &HashMap<&'static str, String>, +) -> HashMap<&'static str, String> { + CUSTOM_CA_ENV_KEYS .into_iter() .filter_map(|key| { env.get(key) .filter(|value| !value.is_empty()) .map(|value| (key, value.clone())) }) - .collect(); - let trust_bundle = build_managed_ca_trust_bundle(cert_path)?; - let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?; + .collect() +} - Ok(ManagedMitmCaTrustBundle { - path, +fn build_managed_ca_trust_bundle( + managed_ca_cert_path: &Path, + startup_env_values: &HashMap<&'static str, String>, + startup_cert_dir: Option<&str>, +) -> Result { + let mut trust_bundle = String::new(); + for cert in load_platform_and_startup_root_certificates( + managed_ca_cert_path, startup_env_values, - }) + startup_cert_dir, + )? { + push_certificate_pem(&mut trust_bundle, cert.as_ref()); + } + append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; + Ok(trust_bundle) } -fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { - let mut trust_bundle = String::new(); +fn load_platform_and_startup_root_certificates( + managed_ca_cert_path: &Path, + startup_env_values: &HashMap<&'static str, String>, + startup_cert_dir: Option<&str>, +) -> Result>> { + let managed_ca_cert = fs::read(managed_ca_cert_path).with_context(|| { + format!( + "failed to read managed MITM CA certificate: {}", + managed_ca_cert_path.display() + ) + })?; + let managed_ca_cert = CertificateDer::from_pem_slice(&managed_ca_cert) + .context("failed to parse managed MITM CA certificate")?; let rustls_native_certs::CertificateResult { certs, errors, .. } = - rustls_native_certs::load_native_certs(); + crate::native_certs::load_platform_native_certs(); if !errors.is_empty() { warn!( native_root_error_count = errors.len(), "encountered errors while loading native root certificates for MITM trust bundle" ); } - for cert in certs { - push_certificate_pem(&mut trust_bundle, cert.as_ref()); + let mut certificates = certs; + let mut appended_startup_paths = HashSet::new(); + for path in CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| startup_env_values.get(key)) + .map(PathBuf::from) + { + if path != managed_ca_cert_path + && !is_current_generated_trust_bundle_path(&path, managed_ca_cert_path) + && appended_startup_paths.insert(path.clone()) + { + certificates.extend(read_ca_certificates(&path)?); + } } - append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; - Ok(trust_bundle) + if let Some(startup_cert_dir) = startup_cert_dir { + for path in std::env::split_paths(startup_cert_dir) { + if appended_startup_paths.insert(path.clone()) { + certificates.extend(load_ca_directory_certificates(&path)); + } + } + } + let mut seen = HashSet::new(); + certificates.retain(|cert| cert != &managed_ca_cert && seen.insert(cert.as_ref().to_vec())); + Ok(certificates) +} + +fn read_ca_certificates(path: &Path) -> Result>> { + let pem = fs::read(path) + .with_context(|| format!("failed to read startup CA bundle: {}", path.display()))?; + let pem = String::from_utf8_lossy(&pem); + let contains_trusted_certificates = pem.contains("TRUSTED CERTIFICATE"); + let normalized_pem = pem + .replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE") + .replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"); + let certs = CertificateDer::pem_slice_iter(normalized_pem.as_bytes()) + .collect::, _>>() + .with_context(|| format!("failed to parse startup CA bundle: {}", path.display()))?; + if certs.is_empty() { + return Err(anyhow!( + "startup CA bundle contained no certificates: {}", + path.display() + )); + } + certs + .into_iter() + .map(|cert| { + let cert = if contains_trusted_certificates { + first_der_item(cert.as_ref()).ok_or_else(|| { + anyhow!( + "startup CA bundle contained an invalid trusted certificate: {}", + path.display() + ) + })? + } else { + cert.as_ref() + }; + Ok(CertificateDer::from(cert.to_vec())) + }) + .collect() +} + +fn load_ca_directory_certificates(path: &Path) -> Vec> { + let rustls_native_certs::CertificateResult { certs, errors, .. } = + rustls_native_certs::load_certs_from_paths(None, Some(path)); + if !errors.is_empty() { + warn!( + ca_path = %path.display(), + ca_error_count = errors.len(), + "encountered errors while loading startup CA directory" + ); + } + certs +} + +fn first_der_item(der: &[u8]) -> Option<&[u8]> { + der_item_length(der).map(|length| &der[..length]) +} + +fn der_item_length(der: &[u8]) -> Option { + let &length_octet = der.get(1)?; + if length_octet & 0x80 == 0 { + return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len()); + } + + let length_octets = usize::from(length_octet & 0x7f); + if length_octets == 0 { + return None; + } + + let length_end = 2usize.checked_add(length_octets)?; + let mut content_length = 0usize; + for &byte in der.get(2..length_end)? { + content_length = content_length + .checked_mul(256)? + .checked_add(usize::from(byte))?; + } + length_end + .checked_add(content_length) + .filter(|length| *length <= der.len()) } fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Path) -> bool { @@ -508,17 +684,80 @@ mod tests { } #[test] - fn managed_ca_trust_bundle_records_startup_ca_env_values() { + fn managed_ca_trust_bundle_appends_startup_file_and_directory_certificates() { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); - fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); - let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + let startup_ca_dir = dir.path().join("startup-certs"); + let (managed_ca_cert, _) = generate_ca().unwrap(); + let (startup_ca_cert, startup_ca_key) = generate_ca().unwrap(); + let (directory_ca_cert, _) = generate_ca().unwrap(); + let mut trusted_ca_der = CertificateDer::from_pem_slice(startup_ca_cert.as_bytes()) + .unwrap() + .as_ref() + .to_vec(); + trusted_ca_der.extend_from_slice(&[0x30, 0x00]); + let mut trusted_ca_cert = String::new(); + push_certificate_pem(&mut trusted_ca_cert, &trusted_ca_der); + let trusted_ca_cert = trusted_ca_cert.replace("CERTIFICATE", "TRUSTED CERTIFICATE"); + fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap(); + fs::write( + &startup_ca_bundle_path, + format!("{trusted_ca_cert}{startup_ca_key}"), + ) + .unwrap(); + fs::create_dir(&startup_ca_dir).unwrap(); + fs::write(startup_ca_dir.join("directory-ca.pem"), &directory_ca_cert).unwrap(); + let startup_ca_bundle_path = startup_ca_bundle_path.display().to_string(); + let env = HashMap::from([ + ("SSL_CERT_FILE", startup_ca_bundle_path.clone()), + (SSL_CERT_DIR_ENV_KEY, startup_ca_dir.display().to_string()), + ]); + let trust_bundle = managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); assert_eq!( trust_bundle.startup_env_values, - HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + HashMap::from([("SSL_CERT_FILE", startup_ca_bundle_path)]) + ); + let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap(); + let baseline_certs = CertificateDer::pem_slice_iter(baseline_bundle.as_bytes()) + .collect::, _>>() + .unwrap(); + let expected_certs = [&startup_ca_cert, &directory_ca_cert, &managed_ca_cert] + .map(|cert| CertificateDer::from_pem_slice(cert.as_bytes()).unwrap()); + + assert!( + expected_certs + .iter() + .all(|cert| baseline_certs.contains(cert)) ); + assert!(!baseline_bundle.contains(&startup_ca_key)); + assert!(!baseline_bundle.contains("TRUSTED CERTIFICATE")); + } + + #[test] + fn managed_ca_trust_bundle_skips_inherited_current_bundle() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let inherited_bundle_path = dir.path().join("ca-bundle-parent.pem"); + let (managed_ca_cert, _) = generate_ca().unwrap(); + fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap(); + fs::write( + &inherited_bundle_path, + format!("parent roots\n{managed_ca_cert}"), + ) + .unwrap(); + let env = HashMap::from([( + "REQUESTS_CA_BUNDLE", + inherited_bundle_path.display().to_string(), + )]); + + let trust_bundle = + managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); + let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap(); + + assert_eq!(baseline_bundle.matches(&managed_ca_cert).count(), 1); } #[cfg(unix)] diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index e80b154a5eae..c8950f1f5592 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -6,6 +6,7 @@ mod connect_policy; mod http_proxy; mod mitm; mod mitm_hook; +mod native_certs; mod network_policy; mod policy; mod proxy; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 345c5b503296..70175ca70602 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -107,11 +107,19 @@ impl MitmState { // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and // apply policy. let ca = ManagedMitmCa::load_or_create()?; + let upstream_tls_root_store = + crate::certs::upstream_tls_root_store(&crate::certs::ca_env_from_process())?; let upstream = if config.allow_upstream_proxy { - UpstreamClient::from_env_proxy_with_allow_local_binding(config.allow_local_binding) + UpstreamClient::from_env_proxy_with_allow_local_binding( + config.allow_local_binding, + upstream_tls_root_store, + ) } else { - UpstreamClient::direct_with_allow_local_binding(config.allow_local_binding) + UpstreamClient::direct_with_allow_local_binding( + config.allow_local_binding, + upstream_tls_root_store, + ) }; Ok(Self { diff --git a/codex-rs/network-proxy/src/native_certs.rs b/codex-rs/network-proxy/src/native_certs.rs new file mode 100644 index 000000000000..6c8d45005db8 --- /dev/null +++ b/codex-rs/network-proxy/src/native_certs.rs @@ -0,0 +1,260 @@ +#[cfg(any(target_os = "macos", windows))] +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rustls_native_certs::CertificateResult; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::Error; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::ErrorKind; + +// `rustls_native_certs::load_native_certs()` first consults SSL_CERT_FILE and +// SSL_CERT_DIR. Load platform roots directly so a startup custom CA can be +// layered onto the managed bundle without replacing the platform trust store. +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + let mut result = + rustls_native_certs::load_certs_from_paths(platform_cert_file().as_deref(), None); + for cert_dir in platform_cert_dirs() { + extend_certificate_result( + &mut result, + rustls_native_certs::load_certs_from_paths(None, Some(&cert_dir)), + ); + } + dedupe_certs(&mut result); + result +} + +#[cfg(target_os = "macos")] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use security_framework::trust_settings::Domain; + use security_framework::trust_settings::TrustSettings; + use security_framework::trust_settings::TrustSettingsForCertificate; + use std::collections::BTreeMap; + + let mut result = CertificateResult::default(); + let mut all_certs = BTreeMap::new(); + for domain in &[Domain::User, Domain::Admin, Domain::System] { + let ts = TrustSettings::new(*domain); + let iter = match ts.iter() { + Ok(iter) => iter, + Err(err) => { + result.errors.push(Error { + context: match domain { + Domain::User => "failed to load user trust settings", + Domain::Admin => "failed to load admin trust settings", + Domain::System => "failed to load system trust settings", + }, + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + + for cert in iter { + let der = cert.to_der(); + let trusted = match ts.tls_trust_settings_for_certificate(&cert) { + Ok(trusted) => trusted.unwrap_or(TrustSettingsForCertificate::TrustRoot), + Err(err) => { + result.errors.push(Error { + context: "certificate not trusted", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + all_certs.entry(der).or_insert(trusted); + } + } + + for (der, trusted) in all_certs { + use TrustSettingsForCertificate::*; + + if let TrustRoot | TrustAsRoot = trusted { + result.certs.push(CertificateDer::from(der)); + } + } + result +} + +#[cfg(windows)] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use schannel::cert_store::CertStore; + + let mut result = CertificateResult::default(); + let current_user_store = match CertStore::open_current_user("ROOT") { + Ok(store) => store, + Err(err) => { + result.errors.push(Error { + context: "failed to open current user certificate store", + kind: ErrorKind::Os(err.into()), + }); + return result; + } + }; + + for cert in current_user_store.certs() { + let valid_uses = match cert.valid_uses() { + Ok(valid_uses) => valid_uses, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate valid uses", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + let is_time_valid = match cert.is_time_valid() { + Ok(is_time_valid) => is_time_valid, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate time validity", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + if usable_for_rustls(valid_uses) && is_time_valid { + result + .certs + .push(CertificateDer::from(cert.to_der().to_vec())); + } + } + result +} + +#[cfg(not(any(all(unix, not(target_os = "macos")), target_os = "macos", windows)))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + rustls_native_certs::load_native_certs() +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extend_certificate_result(result: &mut CertificateResult, extra: CertificateResult) { + result.certs.extend(extra.certs); + result.errors.extend(extra.errors); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn dedupe_certs(result: &mut CertificateResult) { + result.certs.sort_unstable_by(|a, b| a.cmp(b)); + result.certs.dedup(); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_file() -> Option { + PLATFORM_CERTIFICATE_FILE_NAMES + .iter() + .map(std::path::Path::new) + .find(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_dirs() -> impl Iterator { + PLATFORM_CERTIFICATE_DIRS + .iter() + .map(std::path::Path::new) + .filter(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &[ + "/etc/ssl/certs", + "/etc/pki/tls/certs", + "/etc/security/certificates", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs", "/usr/local/share/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + any(target_os = "illumos", target_os = "solaris") +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/certs/CA"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/openssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "aix"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/var/ssl/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "illumos", + target_os = "solaris", + target_os = "netbsd", + target_os = "aix" + )) +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/ssl/cert.pem", + "/opt/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/certs/cacert.pem", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "dragonfly"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/share/certs/ca-root-nss.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/openssl/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "openbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "solaris"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "illumos"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/etc/ssl/cacert.pem", "/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "android"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/data/data/com.termux/files/usr/etc/tls/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "haiku"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/boot/system/data/ssl/CARootCertificates.pem"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "android", + target_os = "haiku", + )) +))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/certs/ca-certificates.crt"]; + +#[cfg(windows)] +fn usable_for_rustls(uses: schannel::cert_context::ValidUses) -> bool { + match uses { + schannel::cert_context::ValidUses::All => true, + schannel::cert_context::ValidUses::Oids(strs) => strs.iter().any(|x| x == PKIX_SERVER_AUTH), + } +} + +#[cfg(windows)] +const PKIX_SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1"; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ef56846599ae..13998dd447ee 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -308,10 +308,7 @@ struct NetworkProxyRuntimeSettings { impl NetworkProxyRuntimeSettings { fn from_config(config: &config::NetworkProxyConfig) -> Result { let mitm_ca_trust_bundle = if config.network.mitm { - let env = crate::certs::CUSTOM_CA_ENV_KEYS - .into_iter() - .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) - .collect(); + let env = crate::certs::ca_env_from_process(); Some(crate::certs::managed_ca_trust_bundle(&env)?) } else { None diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index 3437b0d32deb..5d36706ffb65 100644 --- a/codex-rs/network-proxy/src/upstream.rs +++ b/codex-rs/network-proxy/src/upstream.rs @@ -21,6 +21,8 @@ use rama_net::client::EstablishedClientConnection; use rama_net::http::RequestContext; use rama_tls_rustls::client::TlsConnectorDataBuilder; use rama_tls_rustls::client::TlsConnectorLayer; +use rama_tls_rustls::client::client_root_certs; +use rama_tls_rustls::dep::rustls; use std::sync::Arc; use std::time::Instant; use tracing::info; @@ -104,6 +106,7 @@ impl UpstreamClient { Self::new( ProxyConfig::default(), TargetCheckedTcpConnector::new(state), + client_root_certs(), ) } @@ -111,20 +114,29 @@ impl UpstreamClient { Self::new( ProxyConfig::from_env(), TargetCheckedTcpConnector::new(state), + client_root_certs(), ) } - pub(crate) fn direct_with_allow_local_binding(allow_local_binding: bool) -> Self { + pub(crate) fn direct_with_allow_local_binding( + allow_local_binding: bool, + tls_root_store: Arc, + ) -> Self { Self::new( ProxyConfig::default(), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), + tls_root_store, ) } - pub(crate) fn from_env_proxy_with_allow_local_binding(allow_local_binding: bool) -> Self { + pub(crate) fn from_env_proxy_with_allow_local_binding( + allow_local_binding: bool, + tls_root_store: Arc, + ) -> Self { Self::new( ProxyConfig::from_env(), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), + tls_root_store, ) } @@ -137,8 +149,12 @@ impl UpstreamClient { } } - fn new(proxy_config: ProxyConfig, transport: TargetCheckedTcpConnector) -> Self { - let connector = build_http_connector(transport); + fn new( + proxy_config: ProxyConfig, + transport: TargetCheckedTcpConnector, + tls_root_store: Arc, + ) -> Self { + let connector = build_http_connector(transport, tls_root_store); Self { connector, proxy_config, @@ -221,6 +237,7 @@ impl Service> for UpstreamClient { fn build_http_connector( transport: TargetCheckedTcpConnector, + tls_root_store: Arc, ) -> BoxService< Request, EstablishedClientConnection, Request>, @@ -228,7 +245,10 @@ fn build_http_connector( > { ensure_rustls_crypto_provider(); let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); - let tls_config = TlsConnectorDataBuilder::new() + let client_config = rustls::ClientConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS) + .with_root_certificates(tls_root_store) + .with_no_client_auth(); + let tls_config = TlsConnectorDataBuilder::from(client_config) .with_alpn_protocols_http_auto() .build(); let tls = TlsConnectorLayer::auto() @@ -239,6 +259,10 @@ fn build_http_connector( connector.boxed() } +#[cfg(test)] +#[path = "upstream_tests.rs"] +mod tests; + #[cfg(target_os = "macos")] fn build_unix_connector( path: &str, diff --git a/codex-rs/network-proxy/src/upstream_tests.rs b/codex-rs/network-proxy/src/upstream_tests.rs new file mode 100644 index 000000000000..85f5da9017b7 --- /dev/null +++ b/codex-rs/network-proxy/src/upstream_tests.rs @@ -0,0 +1,111 @@ +use super::*; +use pretty_assertions::assert_eq; +use rama_http::StatusCode; +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rama_tls_rustls::dep::pki_types::PrivateKeyDer; +use rama_tls_rustls::dep::pki_types::pem::PemObject; +use rama_tls_rustls::dep::rcgen::BasicConstraints; +use rama_tls_rustls::dep::rcgen::CertificateParams; +use rama_tls_rustls::dep::rcgen::DistinguishedName; +use rama_tls_rustls::dep::rcgen::DnType; +use rama_tls_rustls::dep::rcgen::ExtendedKeyUsagePurpose; +use rama_tls_rustls::dep::rcgen::IsCa; +use rama_tls_rustls::dep::rcgen::Issuer; +use rama_tls_rustls::dep::rcgen::KeyPair; +use rama_tls_rustls::dep::rcgen::KeyUsagePurpose; +use rama_tls_rustls::dep::rcgen::PKCS_ECDSA_P256_SHA256; +use rama_tls_rustls::dep::tokio_rustls::TlsAcceptor; +use std::collections::HashMap; +use std::fs; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; + +fn generate_ca(common_name: &str) -> (String, KeyPair) { + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let mut distinguished_name = DistinguishedName::new(); + distinguished_name.push(DnType::CommonName, common_name); + params.distinguished_name = distinguished_name; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + (cert.pem(), key_pair) +} + +#[tokio::test] +async fn mitm_upstream_client_trusts_startup_custom_ca() { + ensure_rustls_crypto_provider(); + let temp_dir = tempdir().unwrap(); + let startup_ca_path = temp_dir.path().join("startup-ca.pem"); + let managed_ca_path = temp_dir.path().join("managed-ca.pem"); + let (startup_ca_pem, startup_ca_key) = generate_ca("startup CA"); + let (managed_ca_pem, _) = generate_ca("managed MITM CA"); + fs::write(&startup_ca_path, &startup_ca_pem).unwrap(); + fs::write(&managed_ca_path, managed_ca_pem).unwrap(); + + let issuer = Issuer::from_ca_cert_pem(&startup_ca_pem, startup_ca_key).unwrap(); + let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + server_params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let server_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let server_cert = server_params.signed_by(&server_key, &issuer).unwrap(); + let server_cert = CertificateDer::from_pem_slice(server_cert.pem().as_bytes()).unwrap(); + let server_key = PrivateKeyDer::from_pem_slice(server_key.serialize_pem().as_bytes()).unwrap(); + let mut server_config = + rustls::ServerConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS) + .with_no_client_auth() + .with_single_cert(vec![server_cert], server_key) + .unwrap(); + server_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + let env = HashMap::from([( + "SSL_CERT_FILE", + startup_ca_path.to_string_lossy().into_owned(), + )]); + let roots = + crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &env).unwrap(); + let baseline_roots = + crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &HashMap::new()) + .unwrap(); + assert_eq!(roots.len(), baseline_roots.len() + 1); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut stream = acceptor.accept(stream).await.unwrap(); + let mut request = [0; 4096]; + let bytes_read = stream.read(&mut request).await.unwrap(); + assert!(bytes_read > 0); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + }); + + let client = + UpstreamClient::direct_with_allow_local_binding(/*allow_local_binding*/ true, roots); + let response = client + .serve( + Request::builder() + .uri(format!("https://localhost:{}/", address.port())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + server.await.unwrap(); +} From 0a9b7d2c36937420135efdcfe7b960d34b763d8f Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Mon, 22 Jun 2026 14:24:02 -0700 Subject: [PATCH 09/12] chore: advance tungstenite fork pins (#29480) ## Why `openai-oss-forks/tokio-tungstenite` now includes the updated `tungstenite` fork revision from [openai-oss-forks/tokio-tungstenite#3](https://github.com/openai-oss-forks/tokio-tungstenite/pull/3). Codex should consume the merged fork commit and resolve its direct and transitive `tungstenite` dependencies to the same revision instead of retaining the older pins. ## What Changed - Advanced the `tokio-tungstenite` git pin to `0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186`. - Advanced the `tungstenite` fork pin to `4fffad30fe373adbdcffab9545e9e9bf4f2fc19f` and adjusted the patch source so the transitive dependency resolves to that revision. - Updated `Cargo.lock` and `MODULE.bazel.lock` to match the dependency graph. --- MODULE.bazel.lock | 6 +++--- codex-rs/Cargo.lock | 29 ++++++++++++----------------- codex-rs/Cargo.toml | 6 +++--- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 182600154049..74d66c1f6694 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -986,8 +986,8 @@ "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}", "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib-rs\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", "git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}", "gix-actor_0.40.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\",\"unicode\"],\"name\":\"bstr\",\"req\":\"^1.12.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"gix-date\",\"req\":\"^0.15.0\"},{\"name\":\"gix-error\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.114\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.14\"}],\"features\":{\"serde\":[\"dep:serde\",\"bstr/serde\",\"gix-date/serde\"]}}", @@ -1197,7 +1197,6 @@ "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", "libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", - "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "link-cplusplus_1.0.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"libc++\":[],\"libcxx\":[\"libc++\"],\"libstdc++\":[],\"libstdcxx\":[\"libstdc++\"],\"nothing\":[]}}", "link-section_0.17.2": "{\"dependencies\":[{\"features\":[\"link_section\"],\"name\":\"linktime-proc-macro\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"macrotest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2\"}],\"features\":{\"default\":[\"proc_macro\",\"std\"],\"proc_macro\":[\"dep:linktime-proc-macro\"],\"std\":[]}}", @@ -1876,6 +1875,7 @@ "zerovec_0.11.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.43.2\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"schemars\":[\"dep:schemars\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", "zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", + "zlib-rs_0.5.5": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zlib-rs_0.6.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-api\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 03ed82f1bf91..cd658f9a8812 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -6028,8 +6028,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", - "libz-sys", "miniz_oxide", + "zlib-rs 0.5.5", ] [[package]] @@ -6717,7 +6717,7 @@ dependencies = [ "prodash", "thiserror 2.0.18", "walkdir", - "zlib-rs", + "zlib-rs 0.6.3", ] [[package]] @@ -8770,17 +8770,6 @@ dependencies = [ "webrtc-sys", ] -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "link-cplusplus" version = "1.0.12" @@ -9457,7 +9446,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -10543,7 +10532,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -13608,7 +13597,7 @@ dependencies = [ [[package]] name = "tokio-tungstenite" version = "0.28.0" -source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad" +source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" dependencies = [ "futures-util", "log", @@ -14046,7 +14035,7 @@ dependencies = [ [[package]] name = "tungstenite" version = "0.27.0" -source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f" +source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" dependencies = [ "bytes", "data-encoding", @@ -15807,6 +15796,12 @@ dependencies = [ "zstd 0.13.3", ] +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 667bb7dfdf69..878d35914823 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -546,11 +546,11 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" } ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" } -tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "e5e64b86722c5453c4038131c3193faa49a648ad" } -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } [patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"] -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } From a72433d5606984e71dc37a83a65a6f91af5c84b4 Mon Sep 17 00:00:00 2001 From: jameswt-oai Date: Mon, 22 Jun 2026 14:28:39 -0700 Subject: [PATCH 10/12] [codex-core-plugins] Remote Plugin ID Persisted to File (#27669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR Remote plugin analytics cannot rely only on the in-memory installed-plugin snapshot because that snapshot is refreshed asynchronously after startup. This PR persists the authoritative backend identity alongside each cached remote plugin bundle so later consumers can resolve it without a network request. ### Behavior - Store Codex-owned remote installation metadata in an atomic `.codex-remote-plugin-install.json` sidecar under the plugin cache root. - Use a versioned, snake_case schema: ```json { "schema_version": 1, "remote_plugin_id": "plugins~Plugin_..." } ``` - Write the metadata during remote bundle installation. - Backfill it when bundle sync finds an already-current cached bundle. - Clear it when a generic/local install replaces the cache. - Let existing uninstall and stale-cache removal delete it with the plugin cache root. - Reject unsupported schema versions rather than silently misreading future formats. This PR does not change analytics serialization or event behavior. ### Review surface The implementation is limited to four `codex-core-plugins` files: - `store.rs`: owns the versioned sidecar read/write/remove lifecycle. - `remote_bundle.rs`: persists the backend ID after a remote bundle install. - `remote/remote_installed_plugin_sync.rs`: backfills metadata for an already-current cached bundle. - Tests cover the storage lifecycle and both remote write paths. ## Testing / Validation ### Automated - `just test -p codex-core-plugins` (268 tests passed) - `just fix -p codex-core-plugins` passes with one pre-existing `large_enum_variant` warning in `manifest.rs`. - Coverage verifies the exact filename and JSON schema, identity replacement, local reinstall clearing, uninstall cleanup, remote bundle installation, unsupported schema rejection, and installed-plugin sync backfill. ### Live manual validation Validated the production app-server RPC path with an isolated temporary `CODEX_HOME` and the PR-built Codex binary. The app-server communicated over stdio and did not bind a port. Test plugin: `plugins~Plugin_b80dd84519148191a409cde181c9b3d6` (`build-macos-apps@openai-curated-remote`). 1. Confirmed `plugin/read` initially reported the plugin uninstalled. 2. Installed it through `plugin/install` and confirmed version `0.1.4` was cached. 3. Verified `$CODEX_HOME/plugins/cache/openai-curated-remote/build-macos-apps/.codex-remote-plugin-install.json` was created beside the `0.1.4/` bundle directory with mode `0600` and the expected contents: ```json { "schema_version": 1, "remote_plugin_id": "plugins~Plugin_b80dd84519148191a409cde181c9b3d6" } ``` 4. Deleted only the sidecar, restarted the app-server, and confirmed installed-plugin startup sync recreated it with the same contents. 5. Uninstalled through `plugin/uninstall`, confirmed `plugin/read` returned `installed: false`, and verified the local plugin cache root was removed. 6. Restored the account's original uninstalled state and removed the isolated home and copied credentials. ## Split Overview ```text main ├── #27093 Debug analytics capture merged │ └── #27099 Non-mutating plugin smoke merged │ └── #27100 Remote install/uninstall smoke merged └── #27102 Plugin telemetry metadata refactor merged └── #27669 Persist remote plugin identity ← this PR Next: └── Final PR: add explicit local and remote IDs to plugin analytics ``` This PR is based directly on `main`; prerequisite [#27102](https://github.com/openai/codex/pull/27102) has merged. The original combined [#26281](https://github.com/openai/codex/pull/26281) remains the aggregate reference until the final replacement PR is published. --- .../remote/remote_installed_plugin_sync.rs | 116 ++++++++++++++++ codex-rs/core-plugins/src/remote_bundle.rs | 46 ++++++- codex-rs/core-plugins/src/store.rs | 127 ++++++++++++++++++ codex-rs/core-plugins/src/store_tests.rs | 106 +++++++++++++++ 4 files changed, 393 insertions(+), 2 deletions(-) diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 8041d444b5d7..2a23b066edaa 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -212,6 +212,16 @@ pub async fn sync_remote_installed_plugin_bundles_once( .map(str::trim) .filter(|version| !version.is_empty()); if store.active_plugin_version(&plugin_id).as_deref() == release_version { + if let Err(err) = store.write_remote_plugin_id(&plugin_id, &plugin.id) { + warn!( + remote_plugin_id = %plugin.id, + plugin = %plugin.name, + marketplace = %marketplace_name, + error = %err, + "failed to persist identity for cached remote installed plugin" + ); + failed_remote_plugin_ids.insert(plugin.id); + } continue; } @@ -439,6 +449,13 @@ fn clear_remote_installed_plugin_bundle_sync_in_flight(key: &RemoteInstalledPlug mod tests { use super::*; use pretty_assertions::assert_eq; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; #[test] fn remote_installed_plugin_sync_in_flight_dedupes_by_cache_root() { @@ -461,6 +478,105 @@ mod tests { clear_remote_installed_plugin_bundle_sync_in_flight(&key); } + #[tokio::test] + async fn sync_backfills_remote_plugin_install_metadata_for_current_bundle() { + let server = MockServer::start().await; + let codex_home = tempfile::tempdir().expect("create codex home"); + let cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_GLOBAL_MARKETPLACE_NAME) + .join("linear") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent")) + .expect("create cached plugin manifest parent"); + std::fs::write(&cached_manifest, r#"{"name":"linear","version":"1.2.3"}"#) + .expect("write cached plugin manifest"); + let remote_plugin_id = "plugins~Plugin_linear"; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [{ + "id": remote_plugin_id, + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "version": "1.2.3", + "display_name": "Linear", + "description": "Track work", + "interface": {}, + }, + "enabled": true, + }], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "USER")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + let config = RemotePluginServiceConfig { + chatgpt_base_url: format!("{}/backend-api", server.uri()), + }; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let outcome = sync_remote_installed_plugin_bundles_once( + codex_home.path().to_path_buf(), + &config, + Some(&auth), + ) + .await + .expect("sync current remote plugin bundle"); + + assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default()); + let plugin_id = PluginId::new( + "linear".to_string(), + REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), + ) + .expect("valid plugin id"); + let metadata_path = PluginStore::new(codex_home.path().to_path_buf()) + .plugin_base_root(&plugin_id) + .join(".codex-remote-plugin-install.json"); + assert_eq!( + serde_json::from_str::( + &std::fs::read_to_string(metadata_path.as_path()) + .expect("read remote plugin install metadata") + ) + .expect("parse remote plugin install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": remote_plugin_id, + }) + ); + } + #[test] fn stale_remote_plugin_cleanup_skips_cache_mutations_in_progress() { let codex_home = tempfile::tempdir().expect("create codex home"); diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 49e6bd4138dd..66592dc363ef 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -34,6 +34,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = pub struct ValidatedRemotePluginBundle { pub plugin_id: PluginId, pub plugin_version: String, + remote_plugin_id: String, app_manifest: Option, bundle_download_url: String, } @@ -190,6 +191,7 @@ pub fn validate_remote_plugin_bundle( Ok(ValidatedRemotePluginBundle { plugin_id, plugin_version, + remote_plugin_id: remote_plugin_id.to_string(), app_manifest, bundle_download_url, }) @@ -403,9 +405,12 @@ fn install_remote_plugin_bundle( })?; let store = PluginStore::try_new(codex_home)?; - store + let remote_plugin_id = bundle.remote_plugin_id; + let result = store .install_with_version(plugin_root, bundle.plugin_id, bundle.plugin_version) - .map_err(RemotePluginBundleInstallError::from) + .map_err(RemotePluginBundleInstallError::from)?; + store.write_remote_plugin_id(&result.plugin_id, &remote_plugin_id)?; + Ok(result) } fn extract_remote_plugin_bundle_to_path( @@ -745,6 +750,43 @@ mod tests { ); } + #[test] + fn install_persists_remote_plugin_install_metadata() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let result = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[( + ".codex-plugin/plugin.json", + br#"{"name":"linear","version":"1.2.3"}"#, + /*mode*/ 0o644, + )]), + ) + .expect("install bundle"); + let store = PluginStore::new(codex_home.path().to_path_buf()); + + assert_eq!( + store.remote_plugin_id(&result.plugin_id).unwrap(), + Some(REMOTE_PLUGIN_ID.to_string()) + ); + let metadata_path = store + .plugin_base_root(&result.plugin_id) + .join(".codex-remote-plugin-install.json"); + assert_eq!( + serde_json::from_str::( + &std::fs::read_to_string(metadata_path.as_path()) + .expect("read remote plugin install metadata") + ) + .expect("parse remote plugin install metadata"), + serde_json::json!({ + "schema_version": 1, + "remote_plugin_id": REMOTE_PLUGIN_ID, + }) + ); + } + #[test] fn install_preserves_non_global_bundle_manifest_metadata() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index f0c737e28d4f..5d78fefa90c3 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -7,16 +7,26 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; use semver::Version; use serde::Deserialize; +use serde::Serialize; use serde_json::Value as JsonValue; use std::cmp::Ordering; use std::fs; use std::io; +use std::io::Write; use std::path::Path; use std::path::PathBuf; pub const DEFAULT_PLUGIN_VERSION: &str = "local"; pub const PLUGINS_CACHE_DIR: &str = "plugins/cache"; pub const PLUGINS_DATA_DIR: &str = "plugins/data"; +const REMOTE_PLUGIN_INSTALL_METADATA_FILE: &str = ".codex-remote-plugin-install.json"; +const REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION: u8 = 1; + +#[derive(Debug, Deserialize, Serialize)] +struct RemotePluginInstallMetadata { + schema_version: u8, + remote_plugin_id: String, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallResult { @@ -106,6 +116,102 @@ impl PluginStore { self.active_plugin_version(plugin_id).is_some() } + pub fn remote_plugin_id( + &self, + plugin_id: &PluginId, + ) -> Result, PluginStoreError> { + if !self.is_installed(plugin_id) { + return Ok(None); + } + let path = self.remote_plugin_install_metadata_path(plugin_id); + let contents = match fs::read_to_string(path.as_path()) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(PluginStoreError::io( + "failed to read remote plugin install metadata", + err, + )); + } + }; + let metadata: RemotePluginInstallMetadata = + serde_json::from_str(&contents).map_err(|err| { + PluginStoreError::Invalid(format!( + "failed to parse remote plugin install metadata: {err}" + )) + })?; + if metadata.schema_version != REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION { + return Err(PluginStoreError::Invalid(format!( + "unsupported remote plugin install metadata schema version: {}", + metadata.schema_version + ))); + } + let remote_plugin_id = metadata.remote_plugin_id.trim(); + if remote_plugin_id.is_empty() { + return Err(PluginStoreError::Invalid( + "invalid remote plugin install metadata: remote plugin id must not be blank" + .to_string(), + )); + } + Ok(Some(remote_plugin_id.to_string())) + } + + pub fn write_remote_plugin_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> Result<(), PluginStoreError> { + if !self.is_installed(plugin_id) { + return Err(PluginStoreError::Invalid(format!( + "cannot write remote identity for uninstalled plugin `{}`", + plugin_id.as_key() + ))); + } + let remote_plugin_id = remote_plugin_id.trim(); + if remote_plugin_id.is_empty() { + return Err(PluginStoreError::Invalid( + "invalid remote plugin install metadata: remote plugin id must not be blank" + .to_string(), + )); + } + let path = self.remote_plugin_install_metadata_path(plugin_id); + let parent = path.as_path().parent().ok_or_else(|| { + PluginStoreError::Invalid(format!( + "remote plugin install metadata path has no parent: {}", + path.display() + )) + })?; + let mut contents = serde_json::to_vec_pretty(&RemotePluginInstallMetadata { + schema_version: REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION, + remote_plugin_id: remote_plugin_id.to_string(), + }) + .map_err(|err| { + PluginStoreError::Invalid(format!( + "failed to serialize remote plugin install metadata: {err}" + )) + })?; + contents.push(b'\n'); + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|err| { + PluginStoreError::io( + "failed to create temporary remote plugin install metadata", + err, + ) + })?; + temporary.write_all(&contents).map_err(|err| { + PluginStoreError::io("failed to write remote plugin install metadata", err) + })?; + temporary.as_file_mut().flush().map_err(|err| { + PluginStoreError::io("failed to flush remote plugin install metadata", err) + })?; + temporary.persist(path.as_path()).map_err(|err| { + PluginStoreError::io( + "failed to persist remote plugin install metadata", + err.error, + ) + })?; + Ok(()) + } + pub fn install( &self, source_path: AbsolutePathBuf, @@ -197,6 +303,7 @@ impl PluginStore { &plugin_version, manifest, )?; + self.remove_remote_plugin_install_metadata(&plugin_id)?; Ok(PluginInstallResult { plugin_id, @@ -208,6 +315,26 @@ impl PluginStore { pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> { remove_existing_target(self.plugin_base_root(plugin_id).as_path()) } + + fn remote_plugin_install_metadata_path(&self, plugin_id: &PluginId) -> AbsolutePathBuf { + self.plugin_base_root(plugin_id) + .join(REMOTE_PLUGIN_INSTALL_METADATA_FILE) + } + + fn remove_remote_plugin_install_metadata( + &self, + plugin_id: &PluginId, + ) -> Result<(), PluginStoreError> { + let path = self.remote_plugin_install_metadata_path(plugin_id); + match fs::remove_file(path.as_path()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(PluginStoreError::io( + "failed to remove remote plugin install metadata", + err, + )), + } + } } #[derive(Debug, thiserror::Error)] diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index aa9b17cc29d2..237fb4e88a6d 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_plugin::PluginId; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::tempdir; fn write_plugin_with_version( @@ -191,6 +192,111 @@ fn install_with_version_uses_requested_cache_version() { assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); } +#[test] +fn remote_plugin_install_metadata_follows_installed_cache_lifecycle() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new( + "sample-plugin".to_string(), + "openai-curated-remote".to_string(), + ) + .unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let source = AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(); + + store + .install(source.clone(), plugin_id.clone()) + .expect("install plugin"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") + .expect("write remote identity"); + let metadata_path = store.remote_plugin_install_metadata_path(&plugin_id); + assert_eq!( + metadata_path.as_path().file_name(), + Some(std::ffi::OsStr::new(".codex-remote-plugin-install.json")) + ); + assert_eq!( + serde_json::from_str::( + &fs::read_to_string(metadata_path.as_path()).expect("read install metadata") + ) + .expect("parse install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": "plugins~Plugin_sample", + }) + ); + assert_eq!( + store.remote_plugin_id(&plugin_id).unwrap(), + Some("plugins~Plugin_sample".to_string()) + ); + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_updated") + .expect("replace remote identity"); + assert_eq!( + store.remote_plugin_id(&plugin_id).unwrap(), + Some("plugins~Plugin_updated".to_string()) + ); + assert_eq!( + serde_json::from_str::( + &fs::read_to_string(metadata_path.as_path()).expect("read updated install metadata") + ) + .expect("parse updated install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": "plugins~Plugin_updated", + }) + ); + + store + .install(source, plugin_id.clone()) + .expect("replace with local install"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + assert!(!metadata_path.as_path().exists()); + + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") + .expect("restore remote identity"); + store.uninstall(&plugin_id).expect("uninstall plugin"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + assert!(!metadata_path.as_path().exists()); +} + +#[test] +fn remote_plugin_install_metadata_rejects_unsupported_schema_version() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new( + "sample-plugin".to_string(), + "openai-curated-remote".to_string(), + ) + .unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) + .expect("install plugin"); + fs::write( + store + .remote_plugin_install_metadata_path(&plugin_id) + .as_path(), + r#"{"schema_version":2,"remote_plugin_id":"plugins~Plugin_sample"}"#, + ) + .expect("write unsupported install metadata"); + + let err = store + .remote_plugin_id(&plugin_id) + .expect_err("unsupported schema version should fail"); + + assert_eq!( + err.to_string(), + "unsupported remote plugin install metadata schema version: 2" + ); +} + #[test] fn install_prefers_on_disk_manifest_version_over_fallback() { let tmp = tempdir().unwrap(); From 3a76f3ac68c8949d1cac6ea769b6ec7b8953a415 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 23:36:01 +0200 Subject: [PATCH 11/12] ## New Features - `/usage` can now show and redeem earned usage-limit reset credits, with confirmation, retry, and refreshed availability states. (#28154, #28793) - `/plugins` now organizes remote plugins into OpenAI Curated, Workspace, and Shared with me sections, while eligible turns can recommend and install relevant plugins. (#26703, #28399, #28400, #27704, #28403) - Configurable rollout token budgets track usage across agent threads, provide remaining-budget reminders, and abort turns when exhausted. (#28746, #28494, #28707, #29423) - App-server clients can configure multi-agent delegation as disabled, explicit-request-only, or proactive at the thread and turn level. (#28685, #28792, #29324) - Added an indexed web-search mode that permits live searches while restricting direct page access to server-approved URLs. (#28489) - Codex can now receive scheduled UTC time reminders and query the current time directly, including through client-provided app-server clocks. (#28822, #28824, #28835, #29011) ## Bug Fixes - Restored reliable Linux TUI rendering after suspending with `Ctrl+Z` and resuming with `fg`. (#28342) - Exec-server processes and stdio MCP sessions now survive transient disconnects, including signed-URL refresh and retry-safe stdin writes. (#28512, #28374, #28546, #28895) - Remote environments now preserve executor-native paths, shells, `AGENTS.md` discovery, and sandbox behavior across operating systems. (#28146, #28152, #28958, #28983, #29099, #29108, #29113, #29424) - Plugin loading and installation now handle root marketplace layouts, manifest fallbacks, multiple skill paths, actionable download errors, and immediate tool refreshes. (#28771, #28789, #28790, #28863, #28951) - Parent agents now receive terminal subagent errors instead of seeing failed work as an empty successful completion. (#28375) - Goal-first threads are once again persisted and returned by `thread/list` and `thread/search`. (#28808) ## Chores - Reduced startup and session latency by deferring unnecessary DNS work, warming the model cache, reusing parsed plugin skills, parallelizing skill metadata reads, and skipping redundant catalog synchronization. (#28542, #28699, #28844, #29326, #29005) - Reduced persistent-log churn by removing per-event WebSocket payload logging and filtering duplicated telemetry records. (#29432, #29457) ## Changelog Full Changelog: https://github.com/openai/codex/compare/rust-v0.141.0...rust-v0.142.0 - #28396 [codex] Record external agent import results @charlesgong-openai - #27751 [codex] expose Bedrock credential source in account/read @celia-oai - #28338 [codex] Compress cold active rollouts @jif-oai - #28368 feat: render typed envelopes for multi-agent v2 messages @jif-oai - #28508 [tests] Keep Apps out of generic core test harness @jif-oai - #28472 [codex] Clarify plugin load and runtime capability stages @xl-openai - #28375 core: surface terminal subagent errors to parent agents @jif-oai - #28542 perf(config): defer remote sandbox hostname lookup @fcoury-oai - #28473 path-uri: clarify invalid host path errors @anp-oai - #28342 fix(tui): restore TUI after suspend @fcoury-oai - #28354 [codex] exec-server: stream files in chunks @pakrym-oai - #28553 chore: side prompt @jif-oai - #27099 [codex-app-server-test-client & codex-app-server] Plugin Usage Analytics Smoke Test @jameswt-oai - #28554 fix(tui): highlight C++ module files @fcoury-oai - #28467 [codex] Warn clearly when code mode output is truncated @aibrahim-oai - #27750 [codex] Add incremental thread history changes @wiltzius-openai - #28154 feat(tui): add rate-limit reset redemption to /usage @jayp-oai - #28562 ci: run code-mode unit tests on all bazel targets @cconger - #27923 [codex] Route MCP file uploads through environment filesystem @pakrym-oai - #27100 [codex-app-server-test-client] Plugin Install/Uninstall Analytics Smoke Test @jameswt-oai - #28581 [codex] re-enable absolute workdir integration test @anp-oai - #28468 code-mode: extend test coverage to lock in cell lifecycle @cconger - #28587 [codex] test exec relative additional permissions @anp-oai - #28577 Clarify model-generated and legacy app path types @anp-oai - #28589 Record invariants for path migration. @anp-oai - #28146 app-server: preserve target-native environment cwd @anp-oai - #28595 Tell codex about PathUri serde compat. @anp-oai - #28399 [codex] [1/4] Add recommended plugin endpoint cache @adaley-openai - #28400 [codex] [2/4] Generalize plugin suggestion presentation @adaley-openai - #27704 [codex] [3/4] Activate endpoint plugin recommendations @adaley-openai - #28152 core: render remote environment cwd natively @anp-oai - #28403 [codex] [4/4] Simplify recommended plugin install schema @adaley-openai - #26706 PAC 1 - Add system proxy feature config surface @canvrno-oai - #27910 Add thread recencyAt for sidebar ordering @nornagon-openai - #28627 Revert "Tell codex about PathUri serde compat. (#28595)" @anp-oai - #28625 [codex] Gate remote plugin catalog by auth @xl-openai - #28629 [codex] core: restore absolute turn context cwd @anp-oai - #28642 thread-store: fix response fixture compilation @pakrym-oai - #28580 [codex] Support object-valued plugin MCP manifests @charlesgong-openai - #28599 code-mode: move cell state into library actor @cconger - #28471 [codex] Test code-mode variable truncation @aibrahim-oai - #28655 Revert thread recencyAt for sidebar ordering @pakrym-oai - #28638 core: remove redundant TurnContext and Prompt fields @pakrym-oai - #28656 [codex] Persist built-in image results reported as generating @won-openai - #28512 Resume exec-server sessions after disconnect @jif-oai - #28546 Back off registry retries during exec recovery @jif-oai - #28561 Add join key for MAv2 inter-agent messages @jif-oai - #28699 app-server: keep the model cache warm @jif-oai - #28705 Replace SkillsManager with SkillsService @jif-oai - #27965 [ez][codex-rs] Support apps._default.default_tools_approval_mode @zamoshchin-openai - #28359 Run fs helper through Windows sandbox wrapper @iceweasel-oai - #28628 [codex] Repair invalid skill frontmatter scalars @charlesgong-openai - #28632 Tell codex to avoid changing rollout format. @anp-oai - #28738 Scope command approvals by execution environment @jif-oai - #19047 feat: add run task identity primitives @adrian-openai - #28671 [codex] Restore thread recency with compatible migration history @nornagon-openai - #28768 Extract TUI plugin catalog rendering @canvrno-oai - #28389 [codex] Use compact OpenAI docs search queries @kkahadze-oai - #28681 unified-exec: preserve PathUri through exec-server @anp-oai - #28731 [codex] Track plugin install and import telemetry failures @charlesgong-openai - #28651 exec-server: expose environment registry payloads @viyatb-oai - #28771 fix(plugins): support root local marketplace plugins @caseychow-oai - #28791 bazel: refresh expired macOS SDK pin @anp-oai - #28782 [codex] trace tools build latency @owenlin0 - #28778 path-uri: decouple native path parsing @anp-oai - #28774 feat(exec-server): add Noise rendezvous environment @apanasenko-oai - #28812 [codex] Add optional IDs to response items @pakrym-oai - #28784 fix(install): support older awk checksum parsing @fcoury-oai - #28826 [codex] Use unique IDs for realtime-routed turns @guinness-oai - #27986 [codex] control automatic realtime handoff delivery @jiayuhuang-openai - #28836 [codex] Support assistant realtime append text @guinness-oai - #28374 Refresh signed exec-server URLs on reconnect @apanasenko-oai - #28825 Expose selecte namespaces as direct model tools @won-openai - #28790 [codex] Support plugin manifest path lists @charlesgong-openai - #28851 Record more path migration guidance for codex. @anp-oai - #28780 unified-exec: retain PathUri in command events @anp-oai - #28605 [codex] Split plugin and skill warmup tracing @mzeng-openai - #28608 [codex] Pass plugin namespace into skill loading @mzeng-openai - #28746 [codex] add rollout token budget configuration (1/N) @rka-oai - #28766 Add network environment ID plumbing @jif-oai - #28915 Avoid sandbox helper in apply_patch approval tests @jif-oai - #28813 Pause active goals before TUI interrupts @etraut-openai - #28895 Recover exec process stdin writes @jif-oai - #28940 Pin Windows argument lint to Windows 2022 @rka-oai - #28914 Scope MCP sandbox metadata to server environment @jif-oai - #28911 Add turn-scoped context contributions @jif-oai - #28808 Fix goal-first live threads missing from thread/list @etraut-openai - #25019 [codex] Initialize exec-server OpenTelemetry at startup @starr-openai - #28943 [codex] Fix Windows sandbox runtime ACL refresh @iceweasel-oai - #28946 Synchronize realtime notification test requests @rka-oai - #28822 Add Config for Time Reminders (1/n) @rka-oai - #28494 [codex] rollout budget implementation (2/N) @rka-oai - #27500 Support `openai/form` extended form elicitations @gpeal - #28949 [codex] Make thread store turn filter optional @wiltzius-openai - #28824 current time reminders impl for system clock (2/n) @rka-oai - #27812 [codex] Cache plugin metadata for tool suggestions @mzeng-openai - #28854 apply-patch: carry paths as PathUri @anp-oai - #28835 Add app-server current-time impl (3/n) @rka-oai - #26496 Make auto-review on-request prompt more proactive @maja-openai - #28947 [codex] Remove hardcoded app ID filters @ericning-o - #28959 TUI: improve unified mention selection visibility @canvrno-oai - #27132 Emit Trusted MCP App Identity on Tool-Call Items @martinauyeung-oai - #19049 feat: opt ChatGPT auth into agent identity @adrian-openai - #28770 [connectors] Ignore synthetic links for app accessibility @adaley-openai - #28863 [codex] Preserve remote plugin download status errors @xl-openai - #28958 core: load AGENTS.md from foreign environments @anp-oai - #28789 [codex] Support marketplace plugin manifest fallback @charlesgong-openai - #28993 [codex] Remove child AGENTS.md prompt experiment @pakrym-oai - #28989 core: log AGENTS.md paths as URIs @anp-oai - #28983 core: keep remote exec on reported shell @anp-oai - #28844 [codex] Reuse parsed plugin skills during session startup @xl-openai - #28953 core: add UUIDv7 context window IDs @pakrym-oai - #28951 [plugins] Refresh plugin and tool caches after remote install @adaley-openai - #28856 Always use AVAS for realtime WebRTC calls @bakks - #28814 [codex] Assign response item IDs when recording history @pakrym-oai - #29005 [codex] Skip curated repo sync for remote plugins @xl-openai - #29011 [codex] add clock current-time tool @rka-oai - #29012 core: assign item IDs to compacted replacement history @pakrym-oai - #29022 [codex] Support protected resource OAuth discovery @xl-openai - #28674 [1/3] core: add remote environment connection lifecycle @sayan-oai - #28683 [2/3] core: track starting environments in snapshots @sayan-oai - #29025 [3/3] app-server: configure environment connection timeout @sayan-oai - #28685 Add per-turn multi-agent mode @shijie-oai - #28792 Expose thread-level multi-agent mode @shijie-oai - #28707 [codex] abort turns when rollout budgets expire (token budget 3/3) @rka-oai - #28899 Scope network approvals by environment @jif-oai - #29086 Document raw response item compatibility @jif-oai - #28489 Add indexed web search mode @winston-openai - #28942 Add config toggles for orchestrator skills and MCP @jif-oai - #29099 Keep remote exec commands native to the executor @jif-oai - #29095 Use cached and live web access terminology @winston-openai - #29042 [codex] trace pre-sampling skill and persistence latency @rphilizaire-openai - #29132 chore(deps): advance tokio-tungstenite @apanasenko-oai - #29006 [codex] Preserve skill descriptions outside model context @charlesgong-openai - #29154 Allow resume and settings commands during tasks and MCP startup @etraut-openai - #29256 core: add context window lineage IDs @pakrym-oai - #29259 [codex] prototype mcp_history thread hint injection @pakrym-oai - #29255 [codex] add configurable token budget compaction reminder @pakrym-oai - #29295 [codex] simplify token budget context @pakrym-oai - #29108 Carry sandbox intent to remote exec servers @jif-oai - #29325 Test pipelined scalar exec-server requests @jif-oai - #29326 Parallelize skill metadata stats @jif-oai - #29329 Use controlled time for remote initialization timeout test @jif-oai - #29170 code-mode: define transport-neutral runtime types @cconger - #29285 code-mode: move session ownership into runtime @cconger - #29286 code-mode: linearize cell terminal state @cconger - #29287 code-mode: make session shutdown authoritative @cconger - #29301 [prompting] updated plan mode prompt @rhan-oai - #29288 code-mode: preserve dropped observation output @cconger - #29289 code-mode: preserve initial yield at completion @cconger - #28260 [codex] Add internal auto-compaction opt-out @rhan-oai - #29371 Propagate safety buffering events to app-server clients @fc-oai - #29393 chore: fix merge race (auto-compaction feature access) @sayan-oai - #29327 Persist session IDs across thread resume @jif-oai - #29324 Simplify multi-agent mode controls @jif-oai - #29113 Apply sandbox intent inside remote exec servers @jif-oai - #29001 Add workspace messages app-server API @xli-oai - #29432 Stop logging every Responses WebSocket event @jif-oai - #29073 core: refresh environment context before sampling @sayan-oai - #29455 fix(core): restore thread_source in x-codex-turn-metadata @owenlin0 - #29457 Filter noisy targets from persistent logs @jif-oai - #29429 remove flag for image preparation @rka-oai - #29143 ci: restore custom Windows runner with hermetic LLVM 0.7.9 @anp-oai - #27102 [codex] Centralize Plugin Analytics Metadata @jameswt-oai - #26703 TUI Plugin Sharing 3 - render remote plugin catalog sections @canvrno-oai - #29424 Report remote sandbox denials semantically @jif-oai - #28968 core: rename metadata -> internal_chat_message_metadata_passthrough @owenlin0 - #29464 [sdk/python] Stop advertising HTTP image URLs @rka-oai - #28793 [codex] Fix usage-limit reset copy and state @jayp-oai - #27982 [codex] Start the guardian child session when parent session is started @jgershen-oai - #29468 core: remove unused permissions cwd plumbing @bolinfest - #26707 PAC 2 - Add shared auth system proxy contract @canvrno-oai - #28991 Allow ChatGPT accounts without email @efrazer-oai - #29423 [codex] configure rollout budget reminder thresholds @rka-oai - #26678 permission profiles: expose availability to clients @viyatb-oai - #29476 [codex] handle request_user_input in app-server test client @celia-oai - #29479 fix(config): address permission profile review follow-ups @viyatb-oai - #29014 Honor startup custom CA bundles with managed MITM @winston-openai - #29480 chore: advance tungstenite fork pins @apanasenko-oai - #27669 [codex-core-plugins] Remote Plugin ID Persisted to File @jameswt-oai --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 878d35914823..d46950685187 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -125,7 +125,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.142.0" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From ae7dbe6aecf15dd7c1747a8512acda004d38d6a5 Mon Sep 17 00:00:00 2001 From: Soham Patankar <102520430+yaanfpv@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:07:40 +0530 Subject: [PATCH 12/12] core: add a monitor tool for event-driven background watches Adds a `monitor` tool (gated behind the Monitor feature) that runs a shell command as a long-lived background process and delivers each line it prints (stdout or stderr) back to the model as a notification, so the agent can react to events without polling: a log line appearing, a file changing under `fswatch`, a CI check landing, a build going green. It reuses the existing unified_exec machinery rather than adding its own. The command is spawned through the same process manager, so it lives in the shared process store and is reaped at session shutdown with everything else. A delivery task subscribes as a second consumer of the process output broadcast, coalesces lines within a 200ms window, and wakes an idle session through `try_start_turn_if_idle` (the existing gate for extension-initiated idle work). A flood guard auto-stops a runaway command. action=start returns a monitor id, action=stop ends one watch, and action=list shows what's running. Covered by an end-to-end test in core/tests/suite/monitor.rs. --- codex-rs/core/config.schema.json | 6 + .../src/context/contextual_user_message.rs | 4 + codex-rs/core/src/context/mod.rs | 2 + .../core/src/context/monitor_notification.rs | 36 ++ codex-rs/core/src/session/handlers.rs | 1 + codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 2 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/tools/handlers/mod.rs | 1 + .../core/src/tools/handlers/unified_exec.rs | 2 + .../tools/handlers/unified_exec/monitor.rs | 298 +++++++++++ codex-rs/core/src/tools/spec_plan.rs | 5 + codex-rs/core/src/unified_exec/mod.rs | 3 + codex-rs/core/src/unified_exec/monitor.rs | 466 ++++++++++++++++++ .../core/src/unified_exec/process_manager.rs | 9 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/monitor.rs | 251 ++++++++++ codex-rs/features/src/lib.rs | 9 + 19 files changed, 1100 insertions(+) create mode 100644 codex-rs/core/src/context/monitor_notification.rs create mode 100644 codex-rs/core/src/tools/handlers/unified_exec/monitor.rs create mode 100644 codex-rs/core/src/unified_exec/monitor.rs create mode 100644 codex-rs/core/tests/suite/monitor.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 4615f4e8580c..66893491949a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -557,6 +557,9 @@ "mentions_v2": { "type": "boolean" }, + "monitor": { + "type": "boolean" + }, "multi_agent": { "type": "boolean" }, @@ -4858,6 +4861,9 @@ "mentions_v2": { "type": "boolean" }, + "monitor": { + "type": "boolean" + }, "multi_agent": { "type": "boolean" }, diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index de735b6f1ee7..1c20470c93f1 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -10,6 +10,7 @@ use super::InternalModelContextFragment; use super::LegacyApplyPatchExecCommandWarning; use super::LegacyModelMismatchWarning; use super::LegacyUnifiedExecProcessLimitWarning; +use super::MonitorNotification; use super::RecommendedPluginsInstructions; use super::SkillInstructions; use super::SubagentNotification; @@ -31,6 +32,8 @@ static TURN_ABORTED_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static MONITOR_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); static INTERNAL_MODEL_CONTEXT_REGISTRATION: FragmentRegistrationProxy< InternalModelContextFragment, > = FragmentRegistrationProxy::new(); @@ -54,6 +57,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_SHELL_COMMAND_REGISTRATION, &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, + &MONITOR_NOTIFICATION_REGISTRATION, &INTERNAL_MODEL_CONTEXT_REGISTRATION, &RECOMMENDED_PLUGINS_REGISTRATION, &LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION, diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index c9319ebef0ec..369485377575 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -17,6 +17,7 @@ mod legacy_apply_patch_exec_command_warning; mod legacy_model_mismatch_warning; mod legacy_unified_exec_process_limit_warning; mod model_switch_instructions; +mod monitor_notification; mod multi_agent_mode_instructions; mod network_rule_saved; mod permissions_instructions; @@ -60,6 +61,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; pub(crate) use model_switch_instructions::ModelSwitchInstructions; +pub(crate) use monitor_notification::MonitorNotification; pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions; pub(crate) use network_rule_saved::NetworkRuleSaved; pub use permissions_instructions::PermissionsInstructions; diff --git a/codex-rs/core/src/context/monitor_notification.rs b/codex-rs/core/src/context/monitor_notification.rs new file mode 100644 index 000000000000..9e94c0db9258 --- /dev/null +++ b/codex-rs/core/src/context/monitor_notification.rs @@ -0,0 +1,36 @@ +use super::ContextualUserFragment; + +/// A batch of output lines from a `monitor` watcher, delivered as a contextual +/// user fragment so it stays distinguishable from real user input. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct MonitorNotification { + pub(crate) description: String, + pub(crate) body: String, +} + +impl MonitorNotification { + pub(crate) fn new(description: impl Into, body: impl Into) -> Self { + Self { + description: description.into(), + body: body.into(), + } + } +} + +impl ContextualUserFragment for MonitorNotification { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + format!("\n[{}] {}\n", self.description, self.body) + } +} diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 2feee3e9f340..e42272cd2143 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -593,6 +593,7 @@ async fn shutdown_session_runtime(sess: &Arc) { } sess.abort_all_tasks(TurnAbortReason::Interrupted).await; let _ = sess.conversation.shutdown().await; + sess.services.monitor_manager.abort_all().await; sess.services .unified_exec_manager .terminate_all_processes() diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 32fcfd09e28a..5769b9f61169 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -330,6 +330,7 @@ use crate::tools::parallel::ToolCallRuntime; use crate::tools::sandboxing::ApprovalStore; use crate::turn_timing::TurnTimingState; use crate::turn_timing::record_turn_ttfm_metric; +use crate::unified_exec::MonitorManager; use crate::unified_exec::UnifiedExecProcessManager; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index b4740bea996f..2eff091d5f21 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1018,6 +1018,7 @@ impl Session { unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + monitor_manager: MonitorManager::new(), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 903ea0b477a0..788819493a74 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5061,6 +5061,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + monitor_manager: MonitorManager::new(), shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( @@ -7129,6 +7130,7 @@ where unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + monitor_manager: MonitorManager::new(), shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 853b96e921d7..5cc1bf4394ea 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -18,6 +18,7 @@ use crate::tools::code_mode::CodeModeService; use crate::tools::handlers::ToolSearchHandlerCache; use crate::tools::network_approval::NetworkApprovalService; use crate::tools::sandboxing::ApprovalStore; +use crate::unified_exec::MonitorManager; use crate::unified_exec::UnifiedExecProcessManager; use anyhow::Result; use arc_swap::ArcSwap; @@ -46,6 +47,7 @@ pub(crate) struct SessionServices { pub(crate) mcp_connection_manager: Arc>, pub(crate) mcp_startup_cancellation_token: Mutex, pub(crate) unified_exec_manager: UnifiedExecProcessManager, + pub(crate) monitor_manager: MonitorManager, #[cfg_attr(not(unix), allow(dead_code))] pub(crate) shell_zsh_path: Option, #[cfg_attr(not(unix), allow(dead_code))] diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d1e931aa95ce..03ea29cc7c4d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -76,6 +76,7 @@ pub use test_sync::TestSyncHandler; pub(crate) use tool_search::ToolSearchHandlerCache; pub use unified_exec::ExecCommandHandler; pub(crate) use unified_exec::ExecCommandHandlerOptions; +pub use unified_exec::MonitorHandler; pub use unified_exec::WriteStdinHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 71e776dbf033..c1a7bd699f42 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -18,10 +18,12 @@ use std::sync::Arc; use crate::tools::handlers::parse_arguments; mod exec_command; +mod monitor; mod write_stdin; pub use exec_command::ExecCommandHandler; pub(crate) use exec_command::ExecCommandHandlerOptions; +pub use monitor::MonitorHandler; pub use write_stdin::WriteStdinHandler; #[derive(Debug, Deserialize)] diff --git a/codex-rs/core/src/tools/handlers/unified_exec/monitor.rs b/codex-rs/core/src/tools/handlers/unified_exec/monitor.rs new file mode 100644 index 000000000000..d8bb536dc131 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/unified_exec/monitor.rs @@ -0,0 +1,298 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::parse_arguments; +use crate::tools::handlers::resolve_tool_environment; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; +use crate::unified_exec::ExecCommandRequest; +use crate::unified_exec::UnifiedExecContext; +use crate::unified_exec::spawn_delivery; +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use serde::Deserialize; +use serde_json::json; + +use super::ExecCommandArgs; +use super::get_command; +use super::shell_mode_for_environment; + +const MONITOR_TOOL_NAME: &str = "monitor"; + +/// Time the spawn blocks for initial output before returning. Kept short so the +/// tool call returns quickly while the watcher keeps running in the background. +const MONITOR_YIELD_MS: u64 = 250; + +pub struct MonitorHandler; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum MonitorAction { + Start, + Stop, + List, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MonitorArgs { + action: MonitorAction, + #[serde(default)] + command: Option, + #[serde(default)] + description: Option, + #[serde(default)] + id: Option, +} + +fn create_monitor_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "action".to_string(), + JsonSchema::string_enum( + vec![json!("start"), json!("stop"), json!("list")], + Some("Which monitor operation to perform.".to_string()), + ), + ), + ( + "command".to_string(), + JsonSchema::string(Some( + "Shell command to run as the watcher (action=start). Each line it prints (stdout or stderr) becomes one notification, so filter to the lines you care about (e.g. `tail -F app.log | grep --line-buffered ERROR`).".to_string(), + )), + ), + ( + "description".to_string(), + JsonSchema::string(Some( + "Short label prefixed to every notification this watcher emits (action=start), e.g. \"errors in app.log\".".to_string(), + )), + ), + ( + "id".to_string(), + JsonSchema::string(Some( + "The monitor id returned by a previous start (action=stop).".to_string(), + )), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: MONITOR_TOOL_NAME.to_string(), + description: "Run a shell command as a long-lived background watcher. Each line the command prints (stdout or stderr) is delivered to you as a notification, prefixed with the label; lines emitted close together are batched. The watch ends when the command exits. Use it to react to events without polling: `fswatch ` or `inotifywait -m ` for file changes, `tail -F | grep --line-buffered ` for log signals, or a poll loop for remote state. action=start begins a watch and returns its id; action=stop ends the watch with that id; action=list shows active watches." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(vec!["action".to_string()]), + /*additional_properties*/ Some(false.into()), + ), + output_schema: None, + }) +} + +impl ToolExecutor for MonitorHandler { + fn tool_name(&self) -> ToolName { + ToolName::plain(MONITOR_TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + create_monitor_tool() + } + + fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { + Box::pin(handle_call(invocation)) + } +} + +impl CoreToolRuntime for MonitorHandler {} + +async fn handle_call( + invocation: ToolInvocation, +) -> Result, FunctionCallError> { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + let ToolPayload::Function { arguments } = payload else { + return Err(FunctionCallError::RespondToModel(format!( + "{MONITOR_TOOL_NAME} handler received unsupported payload" + ))); + }; + let args: MonitorArgs = parse_arguments(&arguments)?; + + match args.action { + MonitorAction::Start => { + start(&session, &turn, call_id, args.command, args.description).await + } + MonitorAction::Stop => { + let Some(id) = args.id else { + return Err(FunctionCallError::RespondToModel( + "action=stop requires `id`".to_string(), + )); + }; + let message = match session.services.monitor_manager.remove(&id).await { + Some(process_id) => { + session + .services + .unified_exec_manager + .terminate_process(process_id) + .await; + format!("Stopped monitor {id}.") + } + None => format!("No active monitor with id {id}."), + }; + Ok(text_output(message)) + } + MonitorAction::List => { + let monitors = session.services.monitor_manager.list().await; + let message = if monitors.is_empty() { + "No active monitors.".to_string() + } else { + monitors + .iter() + .map(|m| format!("{} [{}] {}", m.id, m.description, m.command)) + .collect::>() + .join("\n") + }; + Ok(text_output(message)) + } + } +} + +async fn start( + session: &Arc, + turn: &Arc, + call_id: String, + command: Option, + description: Option, +) -> Result, FunctionCallError> { + let command = command.filter(|c| !c.trim().is_empty()).ok_or_else(|| { + FunctionCallError::RespondToModel("action=start requires a non-empty `command`".to_string()) + })?; + let description = description + .filter(|d| !d.trim().is_empty()) + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "action=start requires a non-empty `description`".to_string(), + ) + })?; + + let manager = &session.services.unified_exec_manager; + let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id); + let Some(turn_environment) = + resolve_tool_environment(turn.as_ref(), /* environment_id */ None)? + else { + return Err(FunctionCallError::RespondToModel( + "unified exec is unavailable in this session".to_string(), + )); + }; + let cwd = turn_environment.cwd().clone(); + let environment = Arc::clone(&turn_environment.environment); + let shell_mode = + shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref()); + let shell = turn_environment + .shell + .clone() + .map(Arc::new) + .unwrap_or_else(|| session.user_shell()); + + // Resolve `command` to a concrete shell invocation with the session default + // shell and no permission escalation; the monitor only needs the resolved + // command + shell type back from `get_command`. + let exec_args = ExecCommandArgs { + cmd: command.clone(), + shell: None, + login: None, + tty: false, + yield_time_ms: 0, + max_output_tokens: None, + sandbox_permissions: Default::default(), + additional_permissions: None, + justification: None, + prefix_rule: None, + }; + let resolved = get_command( + &exec_args, + shell, + &shell_mode, + turn.config.permissions.allow_login_shell, + ) + .map_err(FunctionCallError::RespondToModel)?; + + let process_id = manager.allocate_process_id().await; + let request = ExecCommandRequest { + command: resolved.command, + shell_type: resolved.shell_type, + hook_command: command.clone(), + process_id, + yield_time_ms: MONITOR_YIELD_MS, + max_output_tokens: None, + cwd: cwd.clone(), + sandbox_cwd: cwd, + turn_environment: turn_environment.clone(), + shell_mode, + network: turn.network.clone(), + tty: false, + sandbox_permissions: Default::default(), + additional_permissions: None, + additional_permissions_preapproved: false, + justification: None, + prefix_rule: None, + }; + + let initial_output = match manager.exec_command(request, &context).await { + Ok(output) => output, + Err(err) => { + manager.release_process_id(process_id).await; + return Err(FunctionCallError::RespondToModel(format!( + "failed to start monitor: {err:?}" + ))); + } + }; + + // The delivery loop subscribes to the process output stream only inside + // `spawn_delivery`, and the broadcast does not replay to a late subscriber. + // Seed it with whatever the initial yield already captured so the first + // lines (a banner, an already-matching grep) are not dropped. + let id = format!("mon_{}", uuid::Uuid::new_v4()); + let Some(task) = spawn_delivery( + manager, + process_id, + id.clone(), + Arc::downgrade(session), + description.clone(), + initial_output.raw_output, + ) + .await + else { + return Err(FunctionCallError::RespondToModel( + "command exited immediately; a monitor command must keep running and print events" + .to_string(), + )); + }; + + session + .services + .monitor_manager + .insert(id.clone(), process_id, description.clone(), command, task) + .await; + Ok(text_output(format!( + "Started monitor {id}: watching \"{description}\". Stop it with action=stop, id={id}." + ))) +} + +fn text_output(message: String) -> Box { + boxed_tool_output(FunctionToolOutput::from_text( + message, + /*success*/ Some(true), + )) +} diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c83422f1a03b..01762cf4e7d8 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -16,6 +16,7 @@ use crate::tools::handlers::ListAvailablePluginsToInstallHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; use crate::tools::handlers::McpHandler; +use crate::tools::handlers::MonitorHandler; use crate::tools::handlers::NewContextWindowHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadMcpResourceHandler; @@ -722,6 +723,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut planned_tools.add(SleepHandler); } + if features.enabled(Feature::Monitor) { + planned_tools.add(MonitorHandler); + } + if tool_suggest_enabled(turn_context) && let Some(candidates) = context .tool_suggest_candidates diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 34d88c814919..6efbb85a5429 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -46,6 +46,7 @@ use crate::tools::network_approval::DeferredNetworkApproval; mod async_watcher; mod errors; mod head_tail_buffer; +mod monitor; mod process; mod process_manager; mod process_state; @@ -55,6 +56,8 @@ pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) { } pub(crate) use errors::UnifiedExecError; +pub(crate) use monitor::MonitorManager; +pub(crate) use monitor::spawn_delivery; pub(crate) use process::NoopSpawnLifecycle; #[cfg(unix)] pub(crate) use process::SpawnLifecycle; diff --git a/codex-rs/core/src/unified_exec/monitor.rs b/codex-rs/core/src/unified_exec/monitor.rs new file mode 100644 index 000000000000..26ad87ae2874 --- /dev/null +++ b/codex-rs/core/src/unified_exec/monitor.rs @@ -0,0 +1,466 @@ +//! Command-monitor registry and output delivery. +//! +//! A monitor runs a shell command as a long-lived background process and +//! delivers each output line (stdout or stderr) to the session as a +//! notification, waking an idle session at the next turn boundary. It lives +//! inside `unified_exec` so the delivery loop can read the process's +//! `pub(super)` output stream. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Weak; +use std::time::Duration; + +use tokio::sync::Mutex; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::broadcast::error::TryRecvError; +use tokio::task::JoinHandle; +use tokio::time::Instant; +use tokio::time::sleep_until; +use tokio_util::task::AbortOnDropHandle; + +use super::UnifiedExecProcessManager; +use super::async_watcher::TRAILING_OUTPUT_GRACE; +use super::process::UnifiedExecProcess; +use crate::context::ContextualUserFragment; +use crate::context::MonitorNotification; +use crate::session::session::Session; +use codex_protocol::models::ResponseItem; + +/// Lines emitted within this window coalesce into one notification. +const BATCH_WINDOW: Duration = Duration::from_millis(200); + +/// A monitor that emits more than this many lines is auto-stopped so a runaway +/// command cannot wake the agent without bound. +const FLOOD_MAX_LINES: usize = 5000; + +/// A run of bytes with no terminating newline is truncated at this length, so a +/// watcher that streams without newlines (a binary blob, `cat /dev/urandom`) +/// cannot grow the delivery buffer, or one notification, without bound. Each +/// truncation still counts toward the line ceiling, so an endless newline-free +/// stream trips the flood guard instead of exhausting host memory. +const MAX_LINE_BYTES: usize = 16 * 1024; + +/// A snapshot of one active monitor, returned by [`MonitorManager::list`]. +pub(crate) struct MonitorInfo { + pub id: String, + pub description: String, + pub command: String, +} + +struct MonitorEntry { + description: String, + command: String, + process_id: i32, + _task: AbortOnDropHandle<()>, +} + +/// Per-session registry of active monitors. Holds the delivery tasks; the +/// underlying processes live in the shared [`UnifiedExecProcessManager`] store +/// and are reaped by its `terminate_all_processes` at session shutdown. +#[derive(Default)] +pub(crate) struct MonitorManager { + monitors: Mutex>, +} + +impl MonitorManager { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) async fn insert( + &self, + id: String, + process_id: i32, + description: String, + command: String, + task: JoinHandle<()>, + ) { + self.monitors.lock().await.insert( + id, + MonitorEntry { + description, + command, + process_id, + _task: AbortOnDropHandle::new(task), + }, + ); + } + + /// Removes a monitor, returning its process id so the caller can terminate + /// the underlying process. Dropping the entry aborts its delivery task. + pub(crate) async fn remove(&self, id: &str) -> Option { + self.monitors + .lock() + .await + .remove(id) + .map(|entry| entry.process_id) + } + + /// Removes a monitor entry on behalf of its OWN delivery task as that task + /// exits. `remove` aborts the entry's task by dropping its abort-on-drop + /// handle, which is correct for an external `action=stop` but would cancel + /// this task's own final exit-notice delivery; defusing the handle lets the + /// loop prune itself first and still announce the exit. + pub(crate) async fn deregister_self(&self, id: &str) { + if let Some(entry) = self.monitors.lock().await.remove(id) { + let MonitorEntry { _task, .. } = entry; + std::mem::forget(_task); + } + } + + pub(crate) async fn list(&self) -> Vec { + self.monitors + .lock() + .await + .iter() + .map(|(id, entry)| MonitorInfo { + id: id.clone(), + description: entry.description.clone(), + command: entry.command.clone(), + }) + .collect() + } + + /// Aborts every monitor's delivery task. The processes themselves are reaped + /// separately by the unified-exec manager at shutdown. + pub(crate) async fn abort_all(&self) { + self.monitors.lock().await.clear(); + } +} + +/// Spawns the delivery task for an already-running process, returning its +/// handle. Returns `None` if the process is no longer alive (it exited within +/// the spawn's yield window, so it was never a long-lived watcher). `seed` is +/// the output the spawn's initial yield captured before this task subscribed; +/// it is delivered first because the broadcast does not replay to a late +/// subscriber, so without it those early lines would be lost. +pub(crate) async fn spawn_delivery( + manager: &UnifiedExecProcessManager, + process_id: i32, + id: String, + session: Weak, + description: String, + seed: Vec, +) -> Option> { + let process = manager.process_by_id(process_id).await?; + Some(tokio::spawn(delivery_loop( + process, + process_id, + id, + session, + description, + seed, + ))) +} + +async fn delivery_loop( + process: Arc, + process_id: i32, + id: String, + session: Weak, + description: String, + seed: Vec, +) { + let mut rx = process.output_receiver(); + let exit = process.cancellation_token(); + let mut buf: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + // Counts lines delivered plus dropped-chunk markers; bounds the total volume + // a single watcher can wake the agent with. A `Lagged` burst counts by the + // number of chunks it skipped so a runaway watcher trips the ceiling fast. + let mut flood_count: usize = 0; + let mut flush_at: Option = None; + // Set once the process exits: keep draining for a short grace so a final + // chunk racing the cancellation token still lands (mirrors async_watcher). + let mut closing_at: Option = None; + + // Deliver the initial-yield output the broadcast never replayed to us. + if extend_lines( + &mut buf, + &seed, + &mut pending, + &mut flood_count, + &mut flush_at, + ) { + stop_for_flood(&session, &description, &id, process_id, &mut pending).await; + return; + } + + loop { + tokio::select! { + received = rx.recv() => { + let flooded = match received { + Ok(chunk) => { + extend_lines(&mut buf, &chunk, &mut pending, &mut flood_count, &mut flush_at) + } + Err(RecvError::Lagged(skipped)) => { + // The watcher outran the output channel. Surface the gap + // instead of dropping it silently, and count it toward the + // flood guard so a runaway command still trips the ceiling. + flood_count += skipped as usize; + pending.push(format!( + "(dropped ~{skipped} chunks: watcher output too fast)" + )); + if flush_at.is_none() { + flush_at = Some(Instant::now() + BATCH_WINDOW); + } + flood_count >= FLOOD_MAX_LINES + } + Err(RecvError::Closed) => break, + }; + if flooded { + stop_for_flood(&session, &description, &id, process_id, &mut pending).await; + return; + } + } + () = wait_until(flush_at) => { + flush(&session, &description, &mut pending).await; + flush_at = None; + } + () = exit.cancelled(), if closing_at.is_none() => { + closing_at = Some(Instant::now() + TRAILING_OUTPUT_GRACE); + } + () = wait_until(closing_at) => break, + } + } + + // Drain whatever the broadcast still buffers, then deliver the final lines + // and an exit notice so the agent learns the watch ended. A Lagged error is + // surfaced and skipped (matching the in-loop arm) rather than ending the + // drain early and dropping the chunks still queued behind it. + loop { + match rx.try_recv() { + Ok(chunk) => { + extend_lines( + &mut buf, + &chunk, + &mut pending, + &mut flood_count, + &mut flush_at, + ); + } + Err(TryRecvError::Lagged(skipped)) => { + flood_count += skipped as usize; + pending.push(format!( + "(dropped ~{skipped} chunks: watcher output too fast)" + )); + } + Err(_) => break, + } + } + if !buf.is_empty() { + let text = String::from_utf8_lossy(&buf); + let text = text.trim_end(); + if !text.is_empty() { + pending.push(text.to_string()); + } + } + flush(&session, &description, &mut pending).await; + // Prune the registry BEFORE announcing the exit, so a turn the exit notice + // wakes (e.g. an `action=list`) already sees this watcher gone. A concurrent + // `action=stop` may have removed the entry first; this is then a no-op. + if let Some(session) = session.upgrade() { + session.services.monitor_manager.deregister_self(&id).await; + } + deliver(&session, &description, exit_notice(&process)).await; +} + +/// Auto-stops a watcher that hit the flood ceiling: flush what we have, tell the +/// agent, terminate the process, and prune the registry. Used by both the seed +/// and the receive paths. +async fn stop_for_flood( + session: &Weak, + description: &str, + id: &str, + process_id: i32, + pending: &mut Vec, +) { + flush(session, description, pending).await; + // Terminate and deregister before announcing, so the agent wakes to a + // consistent state. + if let Some(session) = session.upgrade() { + session + .services + .unified_exec_manager + .terminate_process(process_id) + .await; + session.services.monitor_manager.deregister_self(id).await; + } + deliver( + session, + description, + format!( + "auto-stopped after {FLOOD_MAX_LINES} lines (flood guard); \ + restart with a tighter filter" + ), + ) + .await; +} + +/// Resolves at `deadline` when set, otherwise never. +async fn wait_until(deadline: Option) { + match deadline { + Some(at) => sleep_until(at).await, + None => std::future::pending().await, + } +} + +/// Splits `chunk` into complete lines, appending each to `pending` and arming +/// the batch timer. Returns `true` if the flood ceiling was reached mid-chunk, +/// so the caller can auto-stop before a single huge chunk blows past the bound. +fn extend_lines( + buf: &mut Vec, + chunk: &[u8], + pending: &mut Vec, + flood_count: &mut usize, + flush_at: &mut Option, +) -> bool { + buf.extend_from_slice(chunk); + loop { + let line: Vec = if let Some(nl) = buf.iter().position(|&b| b == b'\n') { + buf.drain(..=nl).collect() + } else if buf.len() > MAX_LINE_BYTES { + // No newline yet, but the buffer is already pathologically long. + // Emit a truncated prefix so a newline-free stream cannot grow the + // buffer without bound; the remainder keeps draining on later passes, + // and each truncation counts toward the flood ceiling below. + let mut line: Vec = buf.drain(..MAX_LINE_BYTES).collect(); + line.extend_from_slice(b" ... (line truncated)"); + line + } else { + break; + }; + let text = String::from_utf8_lossy(&line); + let text = text.trim_end(); + if text.is_empty() { + continue; + } + pending.push(text.to_string()); + *flood_count += 1; + if flush_at.is_none() { + *flush_at = Some(Instant::now() + BATCH_WINDOW); + } + if *flood_count >= FLOOD_MAX_LINES { + return true; + } + } + false +} + +fn exit_notice(process: &UnifiedExecProcess) -> String { + if let Some(message) = process.failure_message() { + format!("watcher ended: {message}") + } else { + match process.exit_code() { + Some(code) => format!("watcher exited (code {code})"), + None => "watcher exited".to_string(), + } + } +} + +/// Delivers the accumulated batch as one notification and clears it. +async fn flush(session: &Weak, description: &str, pending: &mut Vec) { + if pending.is_empty() { + return; + } + let text = std::mem::take(pending).join("\n"); + deliver(session, description, text).await; +} + +/// Wakes the session with one notification, prefixed by the monitor's label. +/// Injects into a running turn, or starts one when idle. The output is never +/// silently dropped: if the idle start loses a race (a turn began between the +/// two checks) or is refused (Plan mode), the items are recorded against the +/// turn instead. That matters most for the final "watcher exited" notice, which +/// fires after the loop has already deregistered itself and so has no retry. +async fn deliver(session: &Weak, description: &str, body: String) { + let Some(session) = session.upgrade() else { + return; + }; + let items: Vec = vec![ContextualUserFragment::into(MonitorNotification::new( + description, + body, + ))]; + if let Err(items) = session.inject_if_running(items).await + && let Err(err) = session.try_start_turn_if_idle(items).await + { + session.inject_no_new_turn(err.into_input(), None).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn registry_tracks_insert_list_and_remove() { + let manager = MonitorManager::new(); + manager + .insert( + "mon_a".to_string(), + 1, + "watch a".to_string(), + "cmd a".to_string(), + tokio::spawn(async {}), + ) + .await; + manager + .insert( + "mon_b".to_string(), + 2, + "watch b".to_string(), + "cmd b".to_string(), + tokio::spawn(async {}), + ) + .await; + + assert_eq!(manager.list().await.len(), 2); + + // `remove` returns the process id so the caller can terminate it; a + // second remove of the same id is a no-op. + assert_eq!(manager.remove("mon_a").await, Some(1)); + assert_eq!(manager.remove("mon_a").await, None); + + let remaining = manager.list().await; + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, "mon_b"); + + manager.abort_all().await; + assert!(manager.list().await.is_empty()); + } + + #[tokio::test] + async fn deregister_self_removes_entry_without_aborting_its_task() { + let manager = MonitorManager::new(); + // A task that runs until aborted, so we can observe whether it survives. + let task = tokio::spawn(std::future::pending::<()>()); + let handle = task.abort_handle(); + manager + .insert( + "mon_x".to_string(), + 7, + "watch".to_string(), + "cmd".to_string(), + task, + ) + .await; + assert_eq!(manager.list().await.len(), 1); + + manager.deregister_self("mon_x").await; + assert!(manager.list().await.is_empty(), "entry pruned"); + // Unlike `remove`, deregister_self must NOT abort the entry's task: the + // loop removing itself still has its final exit notice to deliver. + tokio::task::yield_now().await; + assert!( + !handle.is_finished(), + "deregister_self must not abort the entry's task" + ); + + // Deregistering an absent id is a no-op. + manager.deregister_self("mon_x").await; + assert!(manager.list().await.is_empty()); + + handle.abort(); + } +} diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 2cb62a6d4b85..126e5ca68269 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -378,6 +378,15 @@ impl UnifiedExecProcessManager { } } + /// Returns the live process for `process_id`, if it is still tracked. + pub(super) async fn process_by_id(&self, process_id: i32) -> Option> { + let store = self.process_store.lock().await; + store + .processes + .get(&process_id) + .map(|entry| Arc::clone(&entry.process)) + } + pub(crate) async fn release_process_id(&self, process_id: i32) { let removed = { let mut store = self.process_store.lock().await; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index a9298ec6d9ed..2ab0222bd938 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -73,6 +73,7 @@ mod model_switching; mod model_visible_layout; mod models_cache_ttl; mod models_etag_responses; +mod monitor; mod multi_agent_mode; mod network_approval; mod openai_file_mcp; diff --git a/codex-rs/core/tests/suite/monitor.rs b/codex-rs/core/tests/suite/monitor.rs new file mode 100644 index 000000000000..c90fd0fcc1e2 --- /dev/null +++ b/codex-rs/core/tests/suite/monitor.rs @@ -0,0 +1,251 @@ +//! Proves the `monitor` tool: a background command's output wakes the agent and +//! reaches the model as a labelled user notification. + +use std::time::Duration; +use std::time::Instant; + +use codex_features::Feature; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +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::skip_if_sandbox; +use core_test_support::test_codex::test_codex; +use serde_json::json; + +/// Starts a monitor on `command` and asserts `marker` reaches the model as a +/// `[signal watch] ...` user message within 20s. Exercises the whole path: the +/// tool call, a real background process, its output line, the idle-wake, and the +/// labelled delivery. +async fn assert_monitor_wakes_with(command: &str, marker: &str) -> anyhow::Result<()> { + let server = start_mock_server().await; + + let args = json!({ + "action": "start", + "command": command, + "description": "signal watch", + }) + .to_string(); + + let mock = mount_sse_sequence( + &server, + vec![ + // User turn: the model calls the monitor tool. + sse(vec![ + ev_response_created("resp-1"), + ev_function_call("call-1", "monitor", &args), + ev_completed("resp-1"), + ]), + // Continuation after the tool result. + sse(vec![ + ev_assistant_message("msg-1", "watching"), + ev_completed("resp-2"), + ]), + // The turn the monitor's output wakes. + sse(vec![ + ev_assistant_message("msg-2", "saw it"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Monitor) + .expect("enable monitor feature"); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "watch for the signal".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let delivered = mock.requests().iter().any(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text.contains("[signal watch]") && text.contains(marker)) + }); + if delivered { + return Ok(()); + } + assert!( + Instant::now() < deadline, + "monitor output never reached the model as a labelled notification" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_stdout_output_wakes_agent() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + // The common case: a stdout line, as fswatch / tail / grep watchers emit. + assert_monitor_wakes_with("sleep 0.5; echo MONITOR_STDOUT; sleep 2", "MONITOR_STDOUT").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_stderr_output_wakes_agent() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + // stderr wakes too: the process output stream the monitor reads carries both. + assert_monitor_wakes_with( + "sleep 0.5; echo MONITOR_STDERR 1>&2; sleep 2", + "MONITOR_STDERR", + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_delivers_unterminated_final_line() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + // A final line with no trailing newline is held until the watch ends, then + // delivered, so a line-oriented watcher never loses its last line. + assert_monitor_wakes_with("sleep 0.5; printf MONITOR_PARTIAL", "MONITOR_PARTIAL").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_flood_guard_auto_stops() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + // A watcher that floods output is auto-stopped and the agent is told why, + // rather than being woken without bound. + assert_monitor_wakes_with("sleep 0.5; seq 1 50000; sleep 5", "flood guard").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_delivers_exit_notice_when_command_ends() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + // When the watched command exits, the agent is woken with an exit notice so + // it learns the watch ended. + assert_monitor_wakes_with("sleep 0.5; true", "watcher exited").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_delivers_output_from_before_the_initial_yield() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + // A line printed before the spawn's initial yield ends is captured in the + // seed and still delivered. The delivery loop subscribes to the output + // stream only after that yield and the broadcast does not replay, so without + // the seed an immediate first line would be lost. + assert_monitor_wakes_with("echo MONITOR_IMMEDIATE; sleep 2", "MONITOR_IMMEDIATE").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_self_prunes_from_registry_when_command_exits() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + // After the watched command exits, the monitor removes itself from the + // registry, so a later `action=list` reports no active monitors instead of a + // dead entry that `stop` could no longer terminate. + let server = start_mock_server().await; + + let start_args = json!({ + "action": "start", + "command": "sleep 1; true", + "description": "short watch", + }) + .to_string(); + let list_args = json!({ "action": "list" }).to_string(); + + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call("call-start", "monitor", &start_args), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "watching"), + ev_completed("resp-2"), + ]), + // The exit notice wakes this turn; the model lists active monitors. + sse(vec![ + ev_response_created("resp-3"), + ev_function_call("call-list", "monitor", &list_args), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_assistant_message("msg-2", "done"), + ev_completed("resp-4"), + ]), + ], + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Monitor) + .expect("enable monitor feature"); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "watch briefly".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Some(output) = mock.function_call_output_text("call-list") { + assert!( + output.contains("No active monitors."), + "expected an empty monitor list after the watch ended, got: {output}" + ); + return Ok(()); + } + assert!( + Instant::now() < deadline, + "the list call never ran after the watched command exited" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn monitor_truncates_a_newline_free_flood() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + // A watcher that streams without newlines must not grow the buffer without + // bound. The run is truncated and delivered with a marker, instead of + // accumulating unbounded host memory while the line-count flood guard sleeps. + assert_monitor_wakes_with( + "head -c 200000 /dev/zero | tr '\\0' x; sleep 2", + "line truncated", + ) + .await +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 933ec73c60da..51831ca8eb58 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -217,6 +217,9 @@ pub enum Feature { CurrentTimeReminder, /// Expose an input-interruptible sleep tool. SleepTool, + /// Expose a command-monitor tool: run a shell command in the background and + /// deliver each output line (stdout or stderr) to the model as a notification. + Monitor, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, /// Prompt Codex Apps connector auth failures through MCP URL elicitations. @@ -1241,6 +1244,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::Monitor, + key: "monitor", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::CollaborationModes, key: "collaboration_modes",