diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 199499826d..ee23da9ee5 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -66,6 +66,12 @@ pub(crate) struct StartupConfig { pub(crate) used_defaults: bool, } +/// Local auth authority and the cloud-backed configuration loaded with it. +pub(crate) struct CloudStartupConfig { + pub(crate) bootstrap: Config, + pub(crate) config: std::io::Result, +} + /// Configuration and policy must finish loading against the same account and cloud snapshot. pub(crate) struct ApplicationPolicyLoad { cloud_config: CloudConfigBundleLoader, @@ -374,6 +380,26 @@ impl ConfigManager { Ok(()) } + /// Load local policy before auth, then fetch the account's cloud configuration. + /// Callers choose their error/recovery policy before initializing any runtimes. + pub(crate) async fn load_cloud_startup_config( + &self, + cwd: Option, + ) -> std::io::Result { + let bootstrap = self.load_startup_config(cwd.clone()).await?.config; + let auth = + AuthManager::shared_from_config(&bootstrap, /*enable_codex_api_key_env*/ false) + .await + .map_err(std::io::Error::other)?; + self.replace_cloud_config_bundle_loader( + auth, + bootstrap.chatgpt_base_url.clone(), + bootstrap.http_client_factory(), + ); + let config = self.load_latest_config(cwd).await; + Ok(CloudStartupConfig { bootstrap, config }) + } + /// Installs local policy before cloud access; failed policy loads block traffic while /// non-strict startup remains available for configuration repair. pub(crate) async fn load_startup_config( diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 5dbce95300..0b2c7faea2 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -104,6 +104,10 @@ mod code_mode_host; mod codex_home_metrics; mod command_exec; mod config_layer; +mod startup_config; +pub use startup_config::StartupConfigArgs; +pub use startup_config::print_startup_config; + mod config_manager; mod config_manager_service; mod connection_cleanup; @@ -546,22 +550,12 @@ pub async fn run_main_with_transport_options( arg0_paths.clone(), Arc::new(NoopThreadConfigLoader), ); - let bootstrap_config = config_manager - .load_startup_config(startup_cwd.clone()) - .await? - .config; - let bootstrap_auth = - AuthManager::shared_from_config(&bootstrap_config, /*enable_codex_api_key_env*/ false) - .await - .map_err(std::io::Error::other)?; - config_manager.replace_cloud_config_bundle_loader( - bootstrap_auth, - bootstrap_config.chatgpt_base_url.clone(), - bootstrap_config.http_client_factory(), - ); + let startup = config_manager + .load_cloud_startup_config(startup_cwd.clone()) + .await?; let mut config_warnings = Vec::new(); let mut plugin_startup_config = PluginStartupConfig::Current; - let config = match config_manager.load_latest_config(startup_cwd.clone()).await { + let config = match startup.config { Ok(config) => config, Err(err) if is_unsupported_untrusted_approval_policy_error(&err) => { return Err(err); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index a40486e0d5..f76e655805 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -537,12 +537,12 @@ use uuid::Uuid; #[cfg(test)] use codex_app_server_protocol::ServerRequest; -mod account_processor; +pub(crate) mod account_processor; mod apps_processor; mod bedrock_auth; mod catalog_processor; mod command_exec_processor; -mod config_processor; +pub(crate) mod config_processor; #[cfg(test)] #[path = "request_processors/config_reload_tests.rs"] mod config_reload_tests; @@ -704,7 +704,7 @@ fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec resolved_roots } -mod config_errors; +pub(crate) mod config_errors; mod request_errors; mod thread_delete; mod thread_goal_processor; 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 5fc3c75ac3..0aa43403f1 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -1009,71 +1009,12 @@ impl AccountRequestProcessor { if self.auth_manager.is_workload_identity_selected() { return Err(self.configured_auth_owned_by_host_error()); } - // Another process may have changed the persisted workspace. Reload both - // account authority and its policy before selecting a grant to remove. - self.auth_manager.reload().await; - self.config_manager.replace_cloud_config_bundle_loader( - Arc::clone(&self.auth_manager), - self.config.chatgpt_base_url.clone(), - self.config.http_client_factory(), - ); - let config = self - .config_manager - .load_latest_config(/*fallback_cwd*/ None) - .await; - let scope = ema_auth_scope(self.auth_manager.auth_cached().as_ref()); - let enterprise_policy_failed = scope.is_some() && config.is_err(); - // Startup policy may belong to another workspace. Never use that fallback - // to select a credential; a policy failure must not block primary logout. - let enterprise_grant = config.as_ref().ok().and_then(|config| { - scope - .as_ref() - .zip(config.mcp_enterprise_managed_auth.as_ref()) - .map(|(scope, profile)| { - ( - profile.idp.credential_name(scope), - profile.idp.issuer.clone(), - config.auth_keyring_backend_kind(), - ) - }) - }); - let config = config.unwrap_or_else(|_| self.config.as_ref().clone()); - self.cancel_active_login().await; - - // Retain the credential lock through primary logout. Otherwise a second - // process can commit after deletion but before the account is removed. - let enterprise_guard = - if let Some((credential_name, issuer, keyring_backend)) = enterprise_grant { - EnterpriseOAuthCredentialGuard::acquire(&credential_name, &issuer, keyring_backend) - .await - .map(Some) - } else { - Ok(None) - }; - let cleanup_failed = match &enterprise_guard { - Ok(Some(guard)) => guard.delete_tokens().is_err(), - Ok(None) => false, - Err(_) => true, - }; - if enterprise_policy_failed || cleanup_failed { - tracing::warn!("Failed to remove enterprise authorization; continuing account logout"); - } - - match self.auth_manager.logout_with_revoke().await { - Ok(_) => {} - Err(err) => { - return Err(internal_error(format!("logout failed: {err}"))); - } - } - drop(enterprise_guard); + let config = + logout_configured_account(&self.config_manager, &self.auth_manager, &self.config) + .await?; self.thread_manager.invalidate_mcp_runtimes().await; - - self.config_manager.clear_cloud_config_bundle_loader(); - - if config.model_provider.is_amazon_bedrock() { - clear_user_model_provider_if_bedrock(&self.config_manager, &config).await?; - } + clear_account_configuration(&self.config_manager, &config).await?; self.model_catalog.invalidate().await; *self.workspace_routing.lock().await = None; @@ -1592,6 +1533,82 @@ fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool { err.status().is_some_and(|status| status.as_u16() == 404) } +/// Remove account credentials using the current account's policy, even during startup recovery. +pub(crate) async fn logout_configured_account( + config_manager: &ConfigManager, + auth_manager: &Arc, + fallback_config: &Config, +) -> Result { + // Another process may have changed the persisted workspace. Reload both + // account authority and its policy before selecting a grant to remove. + auth_manager.reload().await; + config_manager.replace_cloud_config_bundle_loader( + Arc::clone(auth_manager), + fallback_config.chatgpt_base_url.clone(), + fallback_config.http_client_factory(), + ); + let config = config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await; + let scope = ema_auth_scope(auth_manager.auth_cached().as_ref()); + let enterprise_policy_failed = scope.is_some() && config.is_err(); + // Startup policy may belong to another workspace. Never use that fallback + // to select a credential; a policy failure must not block primary logout. + let enterprise_grant = config.as_ref().ok().and_then(|config| { + scope + .as_ref() + .zip(config.mcp_enterprise_managed_auth.as_ref()) + .map(|(scope, profile)| { + ( + profile.idp.credential_name(scope), + profile.idp.issuer.clone(), + config.auth_keyring_backend_kind(), + ) + }) + }); + let config = config.unwrap_or_else(|_| fallback_config.clone()); + + // Retain the credential lock through primary logout. Otherwise a second + // process can commit after deletion but before the account is removed. + let enterprise_guard = + if let Some((credential_name, issuer, keyring_backend)) = enterprise_grant { + EnterpriseOAuthCredentialGuard::acquire(&credential_name, &issuer, keyring_backend) + .await + .map(Some) + } else { + Ok(None) + }; + let cleanup_failed = match &enterprise_guard { + Ok(Some(guard)) => guard.delete_tokens().is_err(), + Ok(None) => false, + Err(_) => true, + }; + if enterprise_policy_failed || cleanup_failed { + tracing::warn!("Failed to remove enterprise authorization; continuing account logout"); + } + + match auth_manager.logout_with_revoke().await { + Ok(_) => {} + Err(err) => { + return Err(internal_error(format!("logout failed: {err}"))); + } + } + drop(enterprise_guard); + Ok(config) +} + +/// Discard account-scoped cloud and provider settings after credentials have been removed. +pub(crate) async fn clear_account_configuration( + config_manager: &ConfigManager, + config: &Config, +) -> Result<(), JSONRPCErrorError> { + config_manager.clear_cloud_config_bundle_loader(); + if config.model_provider.is_amazon_bedrock() { + clear_user_model_provider_if_bedrock(config_manager, config).await?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/app-server/src/request_processors/config_errors.rs b/codex-rs/app-server/src/request_processors/config_errors.rs index 7e93b63ede..1dd9d55ef3 100644 --- a/codex-rs/app-server/src/request_processors/config_errors.rs +++ b/codex-rs/app-server/src/request_processors/config_errors.rs @@ -13,7 +13,7 @@ fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBu None } -pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { +pub(crate) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { let data = cloud_config_bundle_load_error(err).map(|cloud_error| { let mut data = serde_json::json!({ "reason": "cloudConfigBundle", diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index fc3e17e58e..2eaf2c5ffb 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -439,7 +439,7 @@ async fn reload_user_config_inner(config_manager: &ConfigManager, thread_manager } } -fn map_requirements_to_api( +pub(crate) fn map_requirements_to_api( requirements: Option, allowed_login_methods: Vec, ) -> Option { diff --git a/codex-rs/app-server/src/startup_config.rs b/codex-rs/app-server/src/startup_config.rs new file mode 100644 index 0000000000..d265266f7c --- /dev/null +++ b/codex-rs/app-server/src/startup_config.rs @@ -0,0 +1,194 @@ +//! One-shot configuration discovery. Never initializes environments, thread storage, or plugins. + +use crate::config_manager::ConfigManager; +use crate::request_processors::account_processor::clear_account_configuration; +use crate::request_processors::account_processor::logout_configured_account; +use crate::request_processors::config_errors::config_load_error; +use crate::request_processors::config_processor::map_requirements_to_api; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ConfigRequirementsReadResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_arg0::Arg0DispatchPaths; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_core::config::find_codex_home; +use codex_login::AuthManager; +use codex_login::default_client::SetOriginatorError; +use codex_login::default_client::USER_AGENT_SUFFIX; +use codex_login::default_client::set_default_client_residency_requirement; +use codex_login::default_client::set_default_originator; +use codex_login::is_workload_identity_selected; +use codex_login::read_codex_access_token_from_env; +use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; +use std::io; +use std::io::Write; +use std::sync::Arc; + +#[derive(Debug, clap::Args)] +pub struct StartupConfigArgs { + /// Sign out using the managed auth path before loading fresh startup requirements. + #[arg(long, requires = "print_startup_config")] + pub startup_sign_out: bool, + + /// Validate configuration and print requirements as JSON without starting the app server. + #[arg(long, conflicts_with_all = ["listen", "stdio", "remote_control", "managed_daemon"])] + pub print_startup_config: bool, + + /// ClientInfo JSON used for configuration/auth request identity. + #[arg(long, requires = "print_startup_config", value_parser = |value: &str| serde_json::from_str::(value))] + pub startup_client_info: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct StartupConfig { + app_server_version: &'static str, + config_requirements: ConfigRequirementsReadResponse, +} + +/// Writes exactly one JSON value, including structured errors on failure. +pub async fn print_startup_config( + arg0_paths: Arg0DispatchPaths, + cli_config_overrides: CliConfigOverrides, + loader_overrides: LoaderOverrides, + strict_config: bool, + startup: StartupConfigArgs, +) -> io::Result<()> { + let result = read_startup_config( + arg0_paths, + cli_config_overrides, + loader_overrides, + strict_config, + startup, + ) + .await; + let output = match &result { + Ok(config) => serde_json::to_vec(config)?, + Err(error) => serde_json::to_vec(&serde_json::json!({ "error": error }))?, + }; + let mut stdout = io::stdout().lock(); + stdout.write_all(&output)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + result + .map(|_| ()) + .map_err(|_| io::Error::other("startup configuration unavailable")) +} + +async fn read_startup_config( + arg0_paths: Arg0DispatchPaths, + cli_config_overrides: CliConfigOverrides, + loader_overrides: LoaderOverrides, + strict_config: bool, + startup: StartupConfigArgs, +) -> Result { + if startup.startup_sign_out + && (is_workload_identity_selected() || read_codex_access_token_from_env().is_some()) + { + return Err(crate::error_code::invalid_request( + "Configured external authentication is owned by the app-server host and cannot be changed during startup recovery.", + )); + } + if let Some(client) = startup.startup_client_info { + match set_default_originator(client.name.clone()) { + Ok(()) | Err(SetOriginatorError::AlreadyInitialized) => {} + Err(SetOriginatorError::InvalidHeaderValue) => { + return Err(crate::error_code::invalid_params("invalid client name")); + } + } + if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + *suffix = Some(format!("{}; {}", client.name, client.version)); + } + } + let loader_overrides = crate::loader_overrides_with_test_user_config_file( + loader_overrides, + crate::test_user_config_file_from_env(), + ) + .map_err(startup_error)?; + let overrides = cli_config_overrides + .parse_overrides() + .map_err(|error| startup_error(io::Error::new(io::ErrorKind::InvalidInput, error)))?; + let manager = ConfigManager::new( + find_codex_home().map_err(startup_error)?.to_path_buf(), + overrides, + loader_overrides, + strict_config, + Default::default(), + arg0_paths, + Arc::new(NoopThreadConfigLoader), + ); + let cwd = std::env::current_dir().ok(); + let loaded = manager + .load_cloud_startup_config(cwd.clone()) + .await + .map_err(startup_error)?; + let config = match loaded.config { + Ok(config) => config, + // Recovery retains local credential settings when cloud policy is unavailable. + Err(_) if startup.startup_sign_out => loaded.bootstrap.clone(), + Err(error) => return Err(startup_error(error)), + }; + let config = if startup.startup_sign_out { + // Preserve local credential authority while using the effective HTTP route. + let mut auth_config = loaded.bootstrap.auth_config(); + auth_config.auth_route_config = config.auth_route_config(); + auth_config.chatgpt_base_url = Some(config.chatgpt_base_url.clone()); + let auth = AuthManager::shared_from_auth_config( + auth_config, + /*enable_codex_api_key_env*/ false, + ) + .await + .map_err(|error| startup_error(io::Error::other(error)))?; + set_default_client_residency_requirement(config.enforce_residency.value()); + let config = logout_configured_account(&manager, &auth, &config).await?; + clear_account_configuration(&manager, &config).await?; + manager.replace_cloud_config_bundle_loader( + Arc::clone(&auth), + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); + manager + .load_latest_config(cwd) + .await + .map_err(startup_error)? + } else { + config + }; + // Validate and emit the fetched snapshot, or the fresh signed-out snapshot. + config + .validate_windows_mxc_requirement() + .map_err(startup_error)?; + let auth_config = config.auth_config(); + auth_config.validate().map_err(startup_error)?; + let allowed_login_methods = auth_config.managed_auth_policy.allowed_login_methods( + auth_config.forced_login_method, + auth_config.forced_chatgpt_workspace_id.as_deref(), + ); + let requirements = config.config_layer_stack.requirements_toml(); + Ok(StartupConfig { + app_server_version: env!("CARGO_PKG_VERSION"), + config_requirements: ConfigRequirementsReadResponse { + supports_independent_speed_modes: Some(true), + requirements: map_requirements_to_api( + (!requirements.is_empty()).then(|| requirements.clone()), + allowed_login_methods, + ), + }, + }) +} + +fn startup_error(error: io::Error) -> JSONRPCErrorError { + let mut response = config_load_error(&error); + if response.data.is_none() { + let warning = crate::config_warning_from_error("Invalid configuration", &error); + response.data = Some(serde_json::json!({ + "reason": "configLoad", + "filePath": warning.path, + "line": warning.range.as_ref().map(|range| range.start.line), + "column": warning.range.as_ref().map(|range| range.start.column), + "detail": error.to_string(), + })); + } + response +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c3beb4f7c0..72f959fad2 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -570,6 +570,9 @@ struct LogoutCommand { #[derive(Debug, Parser)] struct AppServerCommand { + #[command(flatten)] + startup_config: codex_app_server::StartupConfigArgs, + /// Omit to run the app server; specify a subcommand for tooling. #[command(subcommand)] subcommand: Option, @@ -1237,6 +1240,7 @@ async fn cli_main( } Some(Subcommand::AppServer(app_server_cli)) => { let AppServerCommand { + startup_config, subcommand, code_mode_host, strict_config: app_server_strict_config, @@ -1254,6 +1258,21 @@ async fn cli_main( root_remote_auth_token_env.as_deref(), subcommand.as_ref(), )?; + if startup_config.print_startup_config { + anyhow::ensure!( + subcommand.is_none(), + "--print-startup-config cannot run a subcommand" + ); + return codex_app_server::print_startup_config( + arg0_paths, + root_config_overrides, + LoaderOverrides::default(), + strict_config, + startup_config, + ) + .await + .map_err(Into::into); + } match subcommand { None => { let transport = if stdio { diff --git a/codex-rs/cli/tests/app_server.rs b/codex-rs/cli/tests/app_server.rs index 7a55c4ca52..3688a0b47b 100644 --- a/codex-rs/cli/tests/app_server.rs +++ b/codex-rs/cli/tests/app_server.rs @@ -9,7 +9,13 @@ use tempfile::TempDir; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); - cmd.env("CODEX_HOME", codex_home); + cmd.env("CODEX_HOME", codex_home) + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .env_remove("OPENAI_FEDERATION_RULE_ID") + .env_remove("OPENAI_IDENTITY_TOKEN_FILE") + .env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT"); Ok(cmd) } @@ -125,3 +131,155 @@ fn app_server_emits_json_info_events() -> Result<()> { Ok(()) } + +#[test] +fn print_startup_config_preserves_project_login_restrictions() -> Result<()> { + let home = TempDir::new()?; + let project = TempDir::new()?; + let project_key = + serde_json::to_string(&codex_config::loader::project_trust_key(project.path()))?; + std::fs::write( + home.path().join("config.toml"), + format!("[projects.{project_key}]\ntrust_level = \"trusted\"\n"), + )?; + std::fs::create_dir(project.path().join(".git"))?; + std::fs::create_dir(project.path().join(".codex"))?; + for method in ["chatgpt", "api"] { + std::fs::write( + project.path().join(".codex/config.toml"), + format!("forced_login_method = '{method}'\n"), + )?; + for sign_out in [None, Some("--startup-sign-out")] { + let mut command = codex_command(home.path())?; + command + .current_dir(project.path()) + .args(["app-server", "--print-startup-config"]) + .args(sign_out); + let output = command.assert().success(); + let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout)?; + assert_eq!( + value["configRequirements"]["requirements"]["allowedLoginMethods"], + json!([method]) + ); + } + } + Ok(()) +} + +#[test] +fn print_startup_config_rejects_sign_out_with_environment_access_token() -> Result<()> { + let home = TempDir::new()?; + let auth_path = home.path().join("auth.json"); + let saved_auth = br#"{"OPENAI_API_KEY":"saved-test-key"}"#; + std::fs::write(&auth_path, saved_auth)?; + for token in ["at-test-token", "header.payload.signature"] { + let output = codex_command(home.path())? + .env("CODEX_ACCESS_TOKEN", token) + .args(["app-server", "--print-startup-config", "--startup-sign-out"]) + .assert() + .failure(); + let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout)?; + assert_eq!( + value["error"]["message"], + "Configured external authentication is owned by the app-server host and cannot be changed during startup recovery." + ); + assert_eq!(std::fs::read(&auth_path)?, saved_auth); + } + Ok(()) +} + +#[test] +fn print_startup_config_validates_configuration() -> Result<()> { + let home = TempDir::new()?; + let config_path = home.path().join("config.toml"); + for (config, strict_arg, error) in [ + ("[broken\n", None, Some("failed to load configuration")), + ( + "unknown_future_setting = true\n", + Some("--strict-config"), + Some("unknown configuration field"), + ), + ("unknown_future_setting = true\n", None, None), + ] { + std::fs::write(&config_path, config)?; + let output = codex_command(home.path())? + .args(["app-server", "--print-startup-config"]) + .args(strict_arg) + .assert(); + let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout)?; + if let Some(error) = error { + output.failure().stdout(contains(error)); + assert_eq!(value["error"]["data"]["reason"], "configLoad"); + assert_eq!( + Path::new(value["error"]["data"]["filePath"].as_str().unwrap()).canonicalize()?, + config_path.canonicalize()? + ); + assert!(value["error"]["data"]["line"].as_u64().is_some()); + assert!(value.get("configRequirements").is_none()); + } else { + output.success(); + assert_eq!(value["appServerVersion"], env!("CARGO_PKG_VERSION")); + assert!(value["configRequirements"].get("requirements").is_some()); + assert!(value.get("config").is_none()); + } + } + Ok(()) +} + +#[tokio::test] +async fn print_startup_config_is_independent_of_a_locked_history_database() -> Result<()> { + use sqlx::Executor; + use std::time::Duration; + use tokio::process::Command; + use tokio::time::timeout; + + let home = TempDir::new()?; + let sqlite = TempDir::new()?; + let binary = codex_utils_cargo_bin::cargo_bin("codex")?; + let mut command = Command::new(&binary); + command + .env("CODEX_HOME", home.path()) + .env("CODEX_SQLITE_HOME", sqlite.path()) + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .env_remove("OPENAI_FEDERATION_RULE_ID") + .env_remove("OPENAI_IDENTITY_TOKEN_FILE") + .env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT") + .args(["app-server", "--print-startup-config"]) + .kill_on_drop(true); + let baseline = timeout(Duration::from_secs(10), command.output()).await??; + assert!(baseline.status.success()); + let path = sqlite.path().join("state_5.sqlite"); + let sqlite_config = codex_state::SqliteConfig::new_for_testing( + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(sqlite.path())?, + ); + let pool = sqlite_config.open_read_write_pool(&path).await?; + let mut database = pool.acquire().await?; + database + .execute("CREATE TABLE synthetic_history (id INTEGER)") + .await?; + database.execute("PRAGMA journal_mode=DELETE").await?; + let readers = sqlite_config + .open_read_only_pool(&path, Some(Duration::ZERO)) + .await?; + let mut reader = readers.acquire().await?; + database.execute("BEGIN EXCLUSIVE").await?; + assert!( + reader + .execute("SELECT * FROM synthetic_history") + .await + .is_err() + ); + let before = std::fs::read(&path)?; + let output = timeout(Duration::from_secs(10), command.output()).await??; + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout)?, + serde_json::from_slice::(&baseline.stdout)? + ); + assert_eq!(std::fs::read(&path)?, before); + + database.execute("ROLLBACK").await?; + Ok(()) +} diff --git a/codex-rs/cli/tests/cloud_config.rs b/codex-rs/cli/tests/cloud_config.rs index 7007b2d13e..ade461f8a3 100644 --- a/codex-rs/cli/tests/cloud_config.rs +++ b/codex-rs/cli/tests/cloud_config.rs @@ -132,6 +132,9 @@ impl CloudManagedConfigFixture { .env_remove("CODEX_ACCESS_TOKEN") .env_remove("CODEX_API_KEY") .env_remove("OPENAI_API_KEY") + .env_remove("OPENAI_FEDERATION_RULE_ID") + .env_remove("OPENAI_IDENTITY_TOKEN_FILE") + .env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT") .args(args); Ok(command) } @@ -442,3 +445,137 @@ async fn add_and_remove_preserve_cloud_managed_resources() -> Result<()> { Ok(()) } + +async fn mock_startup_bundle(server: &MockServer, status: u16, config: &str, requirements: &str) { + server.reset().await; + let layer = |contents: &str| { + json!({ + "enterprise_managed": [{"id": "startup", "name": "Startup policy", "contents": contents}] + }) + }; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(status).set_body_json(json!({ + "config_toml": layer(config), "requirements_toml": layer(requirements), + }))) + .expect(1..) + .mount(server) + .await; +} + +async fn successful_json(command: &mut Command) -> Result { + let output = command.output().await?; + ensure!(output.status.success(), "{output:?}"); + Ok(serde_json::from_slice(&output.stdout)?) +} + +#[tokio::test] +async fn startup_config_loads_managed_policy_or_reports_cloud_failure() -> Result<()> { + for status in [200, 401, 503] { + let Some(fixture) = CloudManagedConfigFixture::new().await? else { + return Ok(()); + }; + mock_startup_bundle( + &fixture.server, + status, + "forced_login_method = 'api'\n", + "[application.network.domains]\n'127.0.0.1' = 'allow'\n'example.com' = 'allow'\n", + ) + .await; + let sqlite_home = TempDir::new()?; + let history_path = sqlite_home.path().join("state_5.sqlite"); + std::fs::write(&history_path, b"history must not be repaired")?; + let output = fixture + .command(&["app-server", "--print-startup-config"])? + .env("CODEX_SQLITE_HOME", sqlite_home.path()) + .env( + "CODEX_REFRESH_TOKEN_URL_OVERRIDE", + format!("{}/refresh", fixture.server.uri()), + ) + .output() + .await?; + let value: Value = serde_json::from_slice(&output.stdout)?; + assert_eq!(output.status.success(), status == 200, "{value}"); + if status == 200 { + let requirements = &value["configRequirements"]["requirements"]; + assert_eq!(requirements["allowedLoginMethods"], json!(["api"])); + assert_eq!( + requirements["application"]["network"]["domains"]["example.com"], + "allow" + ); + } else { + assert_eq!(value["error"]["data"]["reason"], "cloudConfigBundle"); + assert!(value.get("configRequirements").is_none()); + } + assert_eq!( + std::fs::read(&history_path)?, + b"history must not be repaired" + ); + assert_eq!(std::fs::read_dir(sqlite_home.path())?.count(), 1); + fixture.server.verify().await; + } + Ok(()) +} + +#[tokio::test] +async fn startup_config_sign_out_reloads_policy_without_starting_runtime() -> Result<()> { + for (status, provider) in [(200, None), (503, None), (503, Some("amazon-bedrock"))] { + let Some(mut fixture) = CloudManagedConfigFixture::new().await? else { + return Ok(()); + }; + if let Some(provider) = provider { + fixture + .user_config + .push_str(&format!("model_provider = \"{provider}\"\n")); + } + std::fs::write( + fixture.codex_home.path().join("config.toml"), + &fixture.user_config, + )?; + mock_startup_bundle( + &fixture.server, status, "forced_login_method = 'api'\n", + "cli_auth_credentials_store = 'ephemeral'\nenforce_residency = 'us'\n[application.network.domains]\n'127.0.0.1' = 'allow'\n[windows]\nrequire_mxc = true\n", + ).await; + let mut revoke = Mock::given(method("POST")).and(path("/revoke")); + if status == 200 { + revoke = revoke.and(header("x-openai-internal-codex-residency", "us")); + } + revoke + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&fixture.server) + .await; + let signed_out_home = TempDir::new()?; + std::fs::write( + signed_out_home.path().join("config.toml"), + &fixture.user_config, + )?; + let baseline = successful_json( + fixture + .command(&["app-server", "--print-startup-config"])? + .env("CODEX_HOME", signed_out_home.path()), + ) + .await?; + let sqlite_home = TempDir::new()?; + let value = successful_json( + fixture + .command(&["app-server", "--print-startup-config", "--startup-sign-out"])? + .env("CODEX_SQLITE_HOME", sqlite_home.path()) + .env( + "CODEX_REVOKE_TOKEN_URL_OVERRIDE", + format!("{}/revoke", fixture.server.uri()), + ), + ) + .await?; + assert_eq!(value["configRequirements"], baseline["configRequirements"]); + assert_eq!(std::fs::read_dir(sqlite_home.path())?.count(), 0); + let user_config: toml::Value = toml::from_str(&std::fs::read_to_string( + fixture.codex_home.path().join("config.toml"), + )?)?; + assert!(user_config.get("model_provider").is_none()); + let status = fixture.command(&["login", "status"])?.output().await?; + assert!(!status.status.success()); + fixture.server.verify().await; + } + Ok(()) +}