From 51e3f94b2b0961bc8a006f5eee4b9a0fbc3bd369 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Mon, 26 Oct 2026 18:30:13 +0000 Subject: [PATCH] Load cloud config for sandbox and remote exec-server commands (#57270) ## Why Sandbox invocations without an explicit permissions profile and remote `exec-server` connections using ChatGPT auth need access to cloud-defined model providers and managed requirements. Cloud bootstrap requests must also respect local application network policy. ## What changed - Bootstrap sandbox cloud config whenever managed config is included, even without an explicit profile. Skip bootstrap when `--sandbox-state-json` supplies the sandbox state. - Load cloud config for remote executors beyond workload identity, while skipping forwarders and executors using stdin credentials, AWS SigV4, or agent identity auth. Honor `CODEX_API_KEY` selection so API key connections skip ChatGPT cloud config. - Apply local application network policy during cloud bootstrap and activate effective application network policy before exec-server clients are created. ## Testing Add coverage for cloud-defined providers in sandbox and remote exec-server invocations, API key bypass, local policy blocking bundle requests, cloud policy blocking remote registration, and bootstrap exclusions. GitOrigin-RevId: e8bd6210751e96161f575ade309ebf41bf10f8b5 --- .../cli/src/debug_sandbox/cloud_config.rs | 9 +- .../src/debug_sandbox/cloud_config_tests.rs | 68 +++++++++-- codex-rs/cli/src/exec_server_args_tests.rs | 15 +++ codex-rs/cli/src/exec_server_command.rs | 45 ++++--- codex-rs/cli/tests/cloud_config.rs | 115 ++++++++++++++++-- .../tests/exec_server/auth_refresh_tests.rs | 8 +- codex-rs/cli/tests/sandbox_cloud_config.rs | 92 ++++++++------ 7 files changed, 273 insertions(+), 79 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox/cloud_config.rs b/codex-rs/cli/src/debug_sandbox/cloud_config.rs index 970af3fb6e..2cbc7445eb 100644 --- a/codex-rs/cli/src/debug_sandbox/cloud_config.rs +++ b/codex-rs/cli/src/debug_sandbox/cloud_config.rs @@ -1,3 +1,4 @@ +use codex_app_server::in_process::EmbeddedNetworkPolicy; use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; @@ -15,7 +16,7 @@ pub(super) async fn bootstrap_cloud_config_bundle( resolve_codex_home: impl FnOnce() -> std::io::Result, strict_config: bool, ) -> anyhow::Result { - if options.permissions_profile.is_none() + if options.sandbox_state.sandbox_state_json.is_some() || !matches!( options.managed_requirements_mode, ManagedRequirementsMode::Include @@ -29,6 +30,7 @@ pub(super) async fn bootstrap_cloud_config_bundle( Some(cwd) => AbsolutePathBuf::relative_to_current_dir(cwd)?, None => AbsolutePathBuf::current_dir()?, }; + let network_policy = EmbeddedNetworkPolicy::load(&options.loader_overrides).await; let bootstrap_config = load_config_toml_with_layer_stack( codex_home.as_path(), Some(&cwd), @@ -41,7 +43,10 @@ pub(super) async fn bootstrap_cloud_config_bundle( ) .await?; Ok(chatgpt_config_bundle_loader_for_storage( - bootstrap_auth_config(codex_home.as_path(), &bootstrap_config)?, + network_policy.bind_bootstrap_auth(bootstrap_auth_config( + codex_home.as_path(), + &bootstrap_config, + )?), /*enable_codex_api_key_env*/ false, ) .await?) diff --git a/codex-rs/cli/src/debug_sandbox/cloud_config_tests.rs b/codex-rs/cli/src/debug_sandbox/cloud_config_tests.rs index d91c6b5de2..4333375c21 100644 --- a/codex-rs/cli/src/debug_sandbox/cloud_config_tests.rs +++ b/codex-rs/cli/src/debug_sandbox/cloud_config_tests.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::write_chatgpt_auth; @@ -36,7 +37,7 @@ enabled = true "#; #[tokio::test] -async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend() -> Result<()> { +async fn sandbox_fetches_managed_profile_only_when_local_policy_allows_backend() -> Result<()> { let server = MockServer::start().await; let expected_requirements = json!([{ "id": "req-managed-cloud", @@ -74,20 +75,45 @@ async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend( AuthCredentialsStoreMode::File, )?; - let options = DebugSandboxConfigOptions { + let mut options = DebugSandboxConfigOptions { sandbox_state: Default::default(), - permissions_profile: Some("managed-cloud".to_string()), + permissions_profile: None, cwd: Some(codex_home.path().to_path_buf()), managed_requirements_mode: ManagedRequirementsMode::Include, loader_overrides: LoaderOverrides::without_managed_config_for_tests(), }; - let cloud_config_bundle = bootstrap_cloud_config_bundle( - &[], - &options, - || AbsolutePathBuf::from_absolute_path(codex_home.path()), - /*strict_config*/ false, - ) - .await?; + let bootstrap = async |options: &DebugSandboxConfigOptions| { + bootstrap_cloud_config_bundle( + &[], + options, + || AbsolutePathBuf::from_absolute_path(codex_home.path()), + /*strict_config*/ false, + ) + .await + }; + let requirements = codex_home.path().join("requirements.toml"); + std::fs::write(&requirements, "[application.network]\nenabled = true\n")?; + options.loader_overrides.system_requirements_path = Some(requirements); + let denied = bootstrap(&options) + .await? + .get() + .await + .expect_err("local policy denies the bundle endpoint"); + assert_eq!( + denied.to_string(), + "destination denied by application network policy" + ); + assert!( + server + .received_requests() + .await + .context("mock request log")? + .is_empty() + ); + + options.permissions_profile = Some("managed-cloud".to_string()); + options.loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let cloud_config_bundle = bootstrap(&options).await?; let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, @@ -135,3 +161,25 @@ async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend( Ok(()) } + +#[tokio::test] +async fn sandbox_state_skips_cloud_bootstrap() -> Result<()> { + let options = DebugSandboxConfigOptions { + sandbox_state: crate::SandboxStateArgs { + sandbox_state_json: Some("{}".into()), + ..Default::default() + }, + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + }; + bootstrap_cloud_config_bundle( + &[], + &options, + || panic!("supplied state must skip CODEX_HOME resolution"), + /*strict_config*/ false, + ) + .await?; + Ok(()) +} diff --git a/codex-rs/cli/src/exec_server_args_tests.rs b/codex-rs/cli/src/exec_server_args_tests.rs index 79178c9865..3292ed4be3 100644 --- a/codex-rs/cli/src/exec_server_args_tests.rs +++ b/codex-rs/cli/src/exec_server_args_tests.rs @@ -72,6 +72,20 @@ fn exec_server_defaults_preserve_local_and_noise_modes() { } } +#[test] +fn remote_forwarder_skips_cloud_config() { + let command = exec_server_from_args(&[ + "forward", + "--connect", + "ws://localhost:8765", + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + ]); + assert!(!command.should_load_cloud_config()); +} + #[test] fn exec_server_parses_direct_aws_options() { for (options, expected) in [ @@ -107,6 +121,7 @@ fn exec_server_parses_direct_aws_options() { assert_eq!(command.environment_id.as_deref(), Some("env-1")); assert_eq!(command.remote_transport, ExecServerRemoteTransport::Direct); assert!(command.aws_sigv4); + assert!(!command.should_load_cloud_config()); assert_eq!( ( command.aws_profile.as_deref(), diff --git a/codex-rs/cli/src/exec_server_command.rs b/codex-rs/cli/src/exec_server_command.rs index ab8ab858e6..52440873be 100644 --- a/codex-rs/cli/src/exec_server_command.rs +++ b/codex-rs/cli/src/exec_server_command.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use clap::Parser; +use codex_app_server::in_process::EmbeddedNetworkPolicy; use codex_arg0::Arg0DispatchPaths; use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_config::LoaderOverrides; @@ -19,7 +20,6 @@ use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use codex_login::AuthManager; use codex_login::CodexAuth; -use codex_login::is_workload_identity_selected; use codex_login::read_codex_access_token_from_env; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; @@ -176,6 +176,7 @@ impl ExecServerCommand { ) -> anyhow::Result<()> { let strict_config = self.strict_config; self.validate_remote_transport()?; + let enable_cloud_config = self.should_load_cloud_config(); let websocket_auth = self.websocket_auth.try_into_settings()?; if websocket_auth.config.is_some() && (self.remote.is_some() || self.command.is_some()) { anyhow::bail!("WebSocket listener auth cannot be used with --remote or forward"); @@ -203,12 +204,9 @@ impl ExecServerCommand { let environment_id = self.environment_id.take().ok_or_else(|| { anyhow::anyhow!("--environment-id is required when --remote is set") })?; - let config = load_exec_server_config( - root_config_overrides, - strict_config, - /*enable_workload_identity*/ !self.executor_credential_stdin, - ) - .await?; + let config = + load_exec_server_config(root_config_overrides, strict_config, enable_cloud_config) + .await?; let direct_transport = self.remote_transport == ExecServerRemoteTransport::Direct; // Configured exporters may contain account-wide credentials or client keys. // The scoped executor must not initialize those exporters. @@ -309,12 +307,9 @@ impl ExecServerCommand { } result } else { - let config_result = load_exec_server_config( - root_config_overrides, - strict_config, - /*enable_workload_identity*/ false, - ) - .await; + let config_result = + load_exec_server_config(root_config_overrides, strict_config, enable_cloud_config) + .await; let config = if strict_config { Some(config_result?) } else { @@ -353,6 +348,14 @@ impl ExecServerCommand { } } + pub(super) fn should_load_cloud_config(&self) -> bool { + self.remote.is_some() + && self.command.is_none() + && !self.executor_credential_stdin + && !self.aws_sigv4 + && !self.use_agent_identity_auth + } + pub(super) fn validate_remote_transport(&self) -> anyhow::Result<()> { match (self.remote_transport, self.aws_sigv4) { (ExecServerRemoteTransport::Noise, true) => { @@ -482,7 +485,7 @@ pub(super) fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()> async fn load_exec_server_config( root_config_overrides: &CliConfigOverrides, strict_config: bool, - enable_workload_identity: bool, + enable_cloud_config: bool, ) -> anyhow::Result { let cli_kv_overrides = root_config_overrides .parse_overrides() @@ -491,15 +494,17 @@ async fn load_exec_server_config( let mut builder = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .strict_config(strict_config); - if enable_workload_identity && is_workload_identity_selected() { + if enable_cloud_config { let codex_home = find_codex_home()?; + let loader_overrides = LoaderOverrides::default(); + let network_policy = EmbeddedNetworkPolicy::load(&loader_overrides).await; let bootstrap_cwd = AbsolutePathBuf::current_dir()?; let bootstrap_config = load_config_toml_with_layer_stack( &codex_home, Some(&bootstrap_cwd), bootstrap_cli_overrides, ConfigLoadOptions { - loader_overrides: LoaderOverrides::default(), + loader_overrides, strict_config, cloud_config_bundle: Default::default(), }, @@ -507,13 +512,15 @@ async fn load_exec_server_config( .await?; let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?; let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( - bootstrap_auth_config, - /*enable_codex_api_key_env*/ false, + network_policy.bind_bootstrap_auth(bootstrap_auth_config), + /*enable_codex_api_key_env*/ true, ) .await?; builder = builder.cloud_config_bundle(cloud_config_bundle); } - Ok(builder.build().await?) + let mut config = builder.build().await?; + EmbeddedNetworkPolicy::default().activate(&mut config); + Ok(config) } async fn load_exec_server_remote_auth( diff --git a/codex-rs/cli/tests/cloud_config.rs b/codex-rs/cli/tests/cloud_config.rs index ade461f8a3..a347297725 100644 --- a/codex-rs/cli/tests/cloud_config.rs +++ b/codex-rs/cli/tests/cloud_config.rs @@ -46,7 +46,7 @@ struct CloudManagedConfigFixture { } impl CloudManagedConfigFixture { - async fn new() -> Result> { + async fn for_backend() -> Result> { let server = MockServer::start().await; let chatgpt_base_url = format!("{}/backend-api", server.uri()); let codex_home = TempDir::new()?; @@ -84,6 +84,19 @@ impl CloudManagedConfigFixture { )?; let mcp_url = format!("{}/mcp", server.uri()); + Ok(Some(Self { + server, + codex_home, + user_config, + mcp_url, + })) + } + + async fn new() -> Result> { + let Some(fixture) = Self::for_backend().await? else { + return Ok(None); + }; + let mcp_url = &fixture.mcp_url; let managed_config = format!( "mcp_oauth_credentials_store = \"file\"\n\n\ [mcp_servers.{MANAGED_SERVER_NAME}]\n\ @@ -110,15 +123,10 @@ impl CloudManagedConfigFixture { }, }))) .expect(1) - .mount(&server) + .mount(&fixture.server) .await; - Ok(Some(Self { - server, - codex_home, - user_config, - mcp_url, - })) + Ok(Some(fixture)) } fn command(&self, args: &[&str]) -> Result { @@ -455,6 +463,7 @@ async fn mock_startup_bundle(server: &MockServer, status: u16, config: &str, req }; Mock::given(method("GET")) .and(path("/backend-api/wham/config/bundle")) + .and(header("authorization", "Bearer chatgpt-token")) .respond_with(ResponseTemplate::new(status).set_body_json(json!({ "config_toml": layer(config), "requirements_toml": layer(requirements), }))) @@ -579,3 +588,93 @@ async fn startup_config_sign_out_reloads_policy_without_starting_runtime() -> Re } Ok(()) } + +const BUNDLE: &str = "/backend-api/wham/config/bundle"; +const REGISTER: &str = "/cloud/environment/test/register"; +const REGISTRATION_REACHED: &str = "registration reached"; + +#[tokio::test] +async fn remote_exec_server_loads_cloud_provider_with_chatgpt_auth() -> Result<()> { + check_registration( + "forced_login_method = 'chatgpt'\nmodel_provider = 'cloud-provider'", + Some(( + "[model_providers.cloud-provider]\nname = 'Cloud provider'\nwire_api = 'responses'", + "", + )), + "Bearer chatgpt-token", + REGISTRATION_REACHED, + &[BUNDLE, REGISTER], + ) + .await +} + +#[tokio::test] +async fn remote_exec_server_api_key_skips_chatgpt_cloud_config() -> Result<()> { + check_registration( + "", + /*bundle*/ None, + "Bearer test-api-key", + REGISTRATION_REACHED, + &[REGISTER], + ) + .await +} + +#[tokio::test] +async fn remote_exec_server_cloud_policy_denies_registration() -> Result<()> { + check_registration( + "forced_login_method = 'chatgpt'", + Some(("", "[application.network]\nenabled = true\n[application.network.domains]\n'gateway.example.com' = 'allow'")), + "Bearer chatgpt-token", + "destination denied by application network policy", + &[BUNDLE], + ) + .await +} + +async fn check_registration( + config: &str, + bundle: Option<(&str, &str)>, + registration_auth: &str, + expected_error: &str, + expected_paths: &[&str], +) -> Result<()> { + let fixture = CloudManagedConfigFixture::for_backend() + .await? + .context("remote registration test requires isolated mock credentials")?; + let server = &fixture.server; + if let Some((config, requirements)) = bundle { + mock_startup_bundle(server, /*status*/ 200, config, requirements).await; + } + Mock::given(method("POST")) + .and(path(REGISTER)) + .and(header("authorization", registration_auth)) + .respond_with(ResponseTemplate::new(400).set_body_string(REGISTRATION_REACHED)) + .mount(server) + .await; + std::fs::write( + fixture.codex_home.path().join("config.toml"), + format!("{}{config}\n", fixture.user_config), + )?; + let mut command = fixture.command(&[ + "exec-server", + "--remote", + &server.uri(), + "--environment-id", + "test", + ])?; + command.env("CODEX_API_KEY", "test-api-key"); + let output = timeout(Duration::from_secs(30), command.output()).await??; + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success() && stderr.contains(expected_error), + "{stderr}" + ); + let requests = server + .received_requests() + .await + .context("mock request log")?; + let paths: Vec<_> = requests.iter().map(|request| request.url.path()).collect(); + assert_eq!(paths, expected_paths); + Ok(()) +} diff --git a/codex-rs/cli/tests/exec_server/auth_refresh_tests.rs b/codex-rs/cli/tests/exec_server/auth_refresh_tests.rs index 193a6f9422..57db55f9e5 100644 --- a/codex-rs/cli/tests/exec_server/auth_refresh_tests.rs +++ b/codex-rs/cli/tests/exec_server/auth_refresh_tests.rs @@ -27,7 +27,9 @@ async fn noise_executor_refreshes_on_401_and_reloads_rotated_credentials() -> Re )?; write_chatgpt_auth( home.path(), - ChatGptAuthFixture::new("old-token").account_id("account"), + ChatGptAuthFixture::new("old-token") + .account_id("account") + .chatgpt_user_id("user"), AuthCredentialsStoreMode::File, )?; Mock::given(path("/refresh")) @@ -94,7 +96,9 @@ async fn noise_executor_refreshes_on_401_and_reloads_rotated_credentials() -> Re // Update storage while the executor is alive, then force a new registration. write_chatgpt_auth( home.path(), - ChatGptAuthFixture::new("rotated-token").account_id("account"), + ChatGptAuthFixture::new("rotated-token") + .account_id("account") + .chatgpt_user_id("user"), AuthCredentialsStoreMode::File, )?; websocket.close(None).await?; diff --git a/codex-rs/cli/tests/sandbox_cloud_config.rs b/codex-rs/cli/tests/sandbox_cloud_config.rs index 81fbf07cf3..dfaded50cd 100644 --- a/codex-rs/cli/tests/sandbox_cloud_config.rs +++ b/codex-rs/cli/tests/sandbox_cloud_config.rs @@ -34,14 +34,53 @@ enabled = true #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Result<()> { + let requirements = json!({"enterprise_managed": [{ + "id": "req-managed-cloud", "name": "Managed permissions", + "contents": CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS, + }]}); + let Some(codex_home) = run_cloud_sandbox( + &["-P", "managed-cloud", "--include-managed-config"], + json!({"requirements_toml": requirements}), + ) + .await? + else { + return Ok(()); + }; + let cache: Value = serde_json::from_slice(&std::fs::read( + codex_home.path().join("cloud-config-bundle-cache.json"), + )?)?; + let payload = &cache["signed_payload"]; + assert_eq!(payload["chatgpt_user_id"], "user-123"); + assert_eq!(payload["account_id"], "workspace-123"); + assert_eq!(payload["bundle"]["requirements_toml"], requirements); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sandbox_loads_cloud_provider_for_cua_style_invocation() -> Result<()> { + run_cloud_sandbox( + &[ + "-c", + "model_provider='cloud-provider'", + "-c", + "default_permissions='cua'", + "-c", + "permissions.cua={extends=':workspace',network={enabled=true}}", + "-c", + "approval_policy='never'", + ], + json!({"config_toml": {"enterprise_managed": [{ + "id": "cloud-provider", "name": "Cloud provider", + "contents": "[model_providers.cloud-provider]\nname = 'Cloud provider'\nwire_api = 'responses'", + }]}}), + ) + .await?; + Ok(()) +} + +async fn run_cloud_sandbox(args: &[&str], bundle: Value) -> Result> { let server = MockServer::start().await; let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let expected_requirements = json!([{ - "id": "req-managed-cloud", - "name": "Managed permissions", - "contents": CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS, - }]); - let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("config.toml"), @@ -73,7 +112,7 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu eprintln!( "skipping cloud-managed sandbox subprocess: host-managed authentication or backend routing prevents isolated mock credentials" ); - return Ok(()); + return Ok(None); } write_chatgpt_auth( @@ -89,11 +128,7 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu .and(path("/backend-api/wham/config/bundle")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "workspace-123")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "requirements_toml": { - "enterprise_managed": expected_requirements.clone(), - }, - }))) + .respond_with(ResponseTemplate::new(200).set_body_json(bundle)) .expect(1) .mount(&server) .await; @@ -107,15 +142,14 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu .env("no_proxy", "127.0.0.1,localhost") .env_remove("CODEX_ACCESS_TOKEN") .env_remove("OPENAI_API_KEY") + .env_remove("OPENAI_FEDERATION_RULE_ID") + .env_remove("OPENAI_IDENTITY_TOKEN_FILE") + .env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT") .args(["-c", "cli_auth_credentials_store=\"file\""]) .args(["-c", chatgpt_base_url_override.as_str()]) - .args([ - "sandbox", - "-P", - "managed-cloud", - "--include-managed-config", - "--", - ]) + .arg("sandbox") + .args(args) + .arg("--") .arg(&codex) .arg("--version") .output()?; @@ -145,24 +179,6 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu ); } - let cache: Value = serde_json::from_slice(&std::fs::read( - codex_home.path().join("cloud-config-bundle-cache.json"), - )?)?; - assert_eq!( - json!({ - "chatgpt_user_id": cache["signed_payload"]["chatgpt_user_id"], - "account_id": cache["signed_payload"]["account_id"], - "requirements_toml": cache["signed_payload"]["bundle"]["requirements_toml"], - }), - json!({ - "chatgpt_user_id": "user-123", - "account_id": "workspace-123", - "requirements_toml": { - "enterprise_managed": expected_requirements, - }, - }), - ); server.verify().await; - - Ok(()) + Ok(Some(codex_home)) }