diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 38f79b8974..7feafda547 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2326,6 +2326,7 @@ dependencies = [ "codex-app-server-test-client", "codex-arg0", "codex-chatgpt", + "codex-cloud-config", "codex-cloud-tasks", "codex-config", "codex-core", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 14e9b51980..53fa06e790 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -28,6 +28,7 @@ codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } codex-api = { workspace = true } codex-chatgpt = { workspace = true } +codex-cloud-config = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 9122d403ce..5f02f0eb82 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -1,3 +1,4 @@ +mod cloud_config; #[cfg(target_os = "macos")] mod pid_tracker; #[cfg(target_os = "macos")] @@ -7,11 +8,13 @@ use std::path::PathBuf; use std::process::Stdio; use anyhow::Context as _; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; +use codex_core::config::find_codex_home; use codex_core::exec_env::create_env; #[cfg(target_os = "macos")] use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; @@ -550,11 +553,20 @@ async fn load_debug_sandbox_config( options: DebugSandboxConfigOptions, strict_config: bool, ) -> anyhow::Result { + let cloud_config_bundle = cloud_config::bootstrap_cloud_config_bundle( + &cli_overrides, + &options, + find_codex_home, + strict_config, + ) + .await?; + load_debug_sandbox_config_with_codex_home( cli_overrides, codex_linux_sandbox_exe, options, /*codex_home*/ None, + cloud_config_bundle, strict_config, ) .await @@ -565,6 +577,7 @@ async fn load_debug_sandbox_config_with_codex_home( codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, ) -> anyhow::Result { let DebugSandboxConfigOptions { @@ -598,6 +611,7 @@ async fn load_debug_sandbox_config_with_codex_home( codex_home.clone(), managed_requirements_mode, loader_overrides.clone(), + cloud_config_bundle.clone(), strict_config, ) .await?; @@ -617,6 +631,7 @@ async fn load_debug_sandbox_config_with_codex_home( codex_home, managed_requirements_mode, loader_overrides, + cloud_config_bundle, strict_config, ) .await @@ -629,11 +644,13 @@ async fn build_debug_sandbox_config_with_loader_overrides( codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, mut loader_overrides: LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() .cli_overrides(cli_overrides) .harness_overrides(harness_overrides) + .cloud_config_bundle(cloud_config_bundle) .strict_config(strict_config); if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { loader_overrides.ignore_managed_requirements = true; @@ -662,9 +679,24 @@ fn cli_overrides_use_legacy_sandbox_mode(cli_overrides: &[(String, TomlValue)]) #[cfg(test)] mod tests { use super::*; + use codex_config::ConfigRequirementsToml; + use codex_config::test_support::CloudConfigBundleFixture; use pretty_assertions::assert_eq; use tempfile::TempDir; + const CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS: &str = r#" +default_permissions = "managed-cloud" + +[allowed_permission_profiles] +managed-cloud = true + +[permissions.managed-cloud] +extends = ":workspace" + +[permissions.managed-cloud.network] +enabled = true +"#; + async fn build_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, @@ -678,6 +710,7 @@ mod tests { codex_home, managed_requirements_mode, LoaderOverrides::default(), + CloudConfigBundleLoader::default(), strict_config, ) .await @@ -761,6 +794,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; @@ -804,6 +838,7 @@ mod tests { Some(codex_home_path.clone()), ManagedRequirementsMode::Include, loader_overrides.clone(), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; @@ -830,6 +865,7 @@ mod tests { loader_overrides, }, Some(codex_home_path), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; @@ -888,6 +924,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; @@ -947,6 +984,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; @@ -975,6 +1013,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; @@ -996,6 +1035,92 @@ mod tests { Ok(()) } + #[tokio::test] + async fn debug_sandbox_honors_explicit_cloud_managed_permission_profile() -> anyhow::Result<()> + { + let codex_home = TempDir::new()?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + sandbox_state: Default::default(), + permissions_profile: Some("managed-cloud".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + }, + Some(codex_home.path().to_path_buf()), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS, + ), + /*strict_config*/ false, + ) + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-cloud".to_string()), + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled, + ); + assert_eq!( + config.config_layer_stack.requirements_toml(), + &toml::from_str::( + CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS, + )?, + ); + + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_ignores_cloud_managed_permission_profiles_by_default() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + sandbox_state: Default::default(), + permissions_profile: Some(":workspace".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + }, + Some(codex_home.path().to_path_buf()), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS, + ), + /*strict_config*/ false, + ) + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(":workspace".to_string()), + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Restricted, + ); + assert_eq!( + config.config_layer_stack.requirements_toml(), + &ConfigRequirementsToml::default(), + ); + + Ok(()) + } + #[tokio::test] async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -1015,6 +1140,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; @@ -1055,6 +1181,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), + CloudConfigBundleLoader::default(), /*strict_config*/ false, ) .await?; diff --git a/codex-rs/cli/src/debug_sandbox/cloud_config.rs b/codex-rs/cli/src/debug_sandbox/cloud_config.rs new file mode 100644 index 0000000000..5155088381 --- /dev/null +++ b/codex-rs/cli/src/debug_sandbox/cloud_config.rs @@ -0,0 +1,72 @@ +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; +use codex_config::ConfigLoadOptions; +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_utils_absolute_path::AbsolutePathBuf; +use toml::Value as TomlValue; + +use super::DebugSandboxConfigOptions; +use super::ManagedRequirementsMode; + +pub(super) async fn bootstrap_cloud_config_bundle( + cli_overrides: &[(String, TomlValue)], + options: &DebugSandboxConfigOptions, + resolve_codex_home: impl FnOnce() -> std::io::Result, + strict_config: bool, +) -> anyhow::Result { + if options.permissions_profile.is_none() + || !matches!( + options.managed_requirements_mode, + ManagedRequirementsMode::Include + ) + { + return Ok(CloudConfigBundleLoader::default()); + } + + let codex_home = resolve_codex_home()?; + let cwd = match options.cwd.as_deref() { + Some(cwd) => AbsolutePathBuf::relative_to_current_dir(cwd)?, + None => AbsolutePathBuf::current_dir()?, + }; + let bootstrap_config = load_config_toml_with_layer_stack( + codex_home.as_path(), + Some(&cwd), + cli_overrides.to_vec(), + ConfigLoadOptions { + loader_overrides: options.loader_overrides.clone(), + strict_config, + cloud_config_bundle: CloudConfigBundleLoader::default(), + }, + ) + .await?; + let bootstrap_config_toml = &bootstrap_config.config_toml; + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; + + Ok(cloud_config_bundle_loader_for_storage( + codex_home.to_path_buf(), + /*enable_codex_api_key_env*/ false, + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), + resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, + bootstrap_config_toml + .chatgpt_base_url + .clone() + .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()), + auth_route_config, + ) + .await) +} + +#[cfg(test)] +#[path = "cloud_config_tests.rs"] +mod tests; diff --git a/codex-rs/cli/src/debug_sandbox/cloud_config_tests.rs b/codex-rs/cli/src/debug_sandbox/cloud_config_tests.rs new file mode 100644 index 0000000000..d91c6b5de2 --- /dev/null +++ b/codex-rs/cli/src/debug_sandbox/cloud_config_tests.rs @@ -0,0 +1,137 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_config::ConfigRequirementsToml; +use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::super::DebugSandboxConfigOptions; +use super::super::ManagedRequirementsMode; +use super::super::load_debug_sandbox_config_with_codex_home; +use super::bootstrap_cloud_config_bundle; + +const CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS: &str = r#" +default_permissions = "managed-cloud" + +[allowed_permission_profiles] +managed-cloud = true + +[permissions.managed-cloud] +extends = ":workspace" + +[permissions.managed-cloud.network] +enabled = true +"#; + +#[tokio::test] +async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend() -> Result<()> { + let server = MockServer::start().await; + let expected_requirements = json!([{ + "id": "req-managed-cloud", + "name": "Managed permissions", + "contents": CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS, + }]); + Mock::given(method("GET")) + .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(), + }, + }))) + .expect(1) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{}/backend-api\"\n", + server.uri(), + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("workspace-123") + .chatgpt_account_id("workspace-123") + .chatgpt_user_id("user-123") + .plan_type("enterprise"), + AuthCredentialsStoreMode::File, + )?; + + let options = DebugSandboxConfigOptions { + sandbox_state: Default::default(), + permissions_profile: Some("managed-cloud".to_string()), + 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 config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + options, + Some(codex_home.path().to_path_buf()), + cloud_config_bundle, + /*strict_config*/ false, + ) + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-cloud".to_string()), + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled, + ); + assert_eq!( + config.config_layer_stack.requirements_toml(), + &toml::from_str::(CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,)?, + ); + + 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(()) +} diff --git a/codex-rs/cli/tests/sandbox_cloud_config.rs b/codex-rs/cli/tests/sandbox_cloud_config.rs new file mode 100644 index 0000000000..81fbf07cf3 --- /dev/null +++ b/codex-rs/cli/tests/sandbox_cloud_config.rs @@ -0,0 +1,168 @@ +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_config::ConfigLoadOptions; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::load_config_toml_with_layer_stack; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS: &str = r#" +default_permissions = "managed-cloud" + +[allowed_permission_profiles] +managed-cloud = true + +[permissions.managed-cloud] +extends = ":workspace" + +[permissions.managed-cloud.network] +enabled = true +"#; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> 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"), + format!( + "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{chatgpt_base_url}\"\n", + ), + )?; + let bootstrap_config = load_config_toml_with_layer_stack( + codex_home.path(), + Some(&AbsolutePathBuf::from_absolute_path(codex_home.path())?), + vec![ + ( + "cli_auth_credentials_store".to_string(), + toml::Value::String("file".to_string()), + ), + ( + "chatgpt_base_url".to_string(), + toml::Value::String(chatgpt_base_url.clone()), + ), + ], + ConfigLoadOptions::default(), + ) + .await?; + if bootstrap_config.config_toml.cli_auth_credentials_store + != Some(AuthCredentialsStoreMode::File) + || bootstrap_config.config_toml.chatgpt_base_url.as_deref() + != Some(chatgpt_base_url.as_str()) + { + eprintln!( + "skipping cloud-managed sandbox subprocess: host-managed authentication or backend routing prevents isolated mock credentials" + ); + return Ok(()); + } + + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("workspace-123") + .chatgpt_account_id("workspace-123") + .chatgpt_user_id("user-123") + .plan_type("enterprise"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .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(), + }, + }))) + .expect(1) + .mount(&server) + .await; + + let codex = codex_utils_cargo_bin::cargo_bin("codex")?; + let chatgpt_base_url_override = format!("chatgpt_base_url=\"{chatgpt_base_url}\""); + let output = Command::new(&codex) + .current_dir(codex_home.path()) + .env("CODEX_HOME", codex_home.path()) + .env("NO_PROXY", "127.0.0.1,localhost") + .env("no_proxy", "127.0.0.1,localhost") + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("OPENAI_API_KEY") + .args(["-c", "cli_auth_credentials_store=\"file\""]) + .args(["-c", chatgpt_base_url_override.as_str()]) + .args([ + "sandbox", + "-P", + "managed-cloud", + "--include-managed-config", + "--", + ]) + .arg(&codex) + .arg("--version") + .output()?; + let cloud_bundle_request_paths: Vec<_> = server + .received_requests() + .await + .context("failed to read mock cloud configuration requests")? + .into_iter() + .map(|request| request.url.path().to_string()) + .collect(); + let stderr = String::from_utf8_lossy(&output.stderr); + let nested_macos_sandbox_unavailable = cfg!(target_os = "macos") + && output.status.code() == Some(71) + && stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"); + + assert!( + output.status.success() || nested_macos_sandbox_unavailable, + "cloud-managed sandbox profile was not enforced: status={:?}; stdout={}; stderr={}; cloud bundle requests={cloud_bundle_request_paths:?}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + stderr, + ); + if !nested_macos_sandbox_unavailable { + assert!( + String::from_utf8(output.stdout)?.starts_with("codex"), + "expected the sandboxed Codex version command to run", + ); + } + + 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(()) +}