From d45cd2624848b185d10ab2f29b40fd581f407ac8 Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 13:18:59 -0700 Subject: [PATCH] Switch runtime to cloud config bundle (#24622) ## Summary - Adapts the moved `codex-cloud-config` crate from the legacy cloud requirements endpoint to the new config bundle endpoint. - Switches runtime consumers from `CloudRequirementsLoader` to `CloudConfigBundleLoader` so one shared bundle supplies cloud-delivered config and requirements. - Removes the legacy cloud requirements domain loader path. ## Details This intentionally keeps `codex-cloud-config` monolithic for review lineage: the previous PR establishes the crate move, and this PR shows the behavior change against that moved implementation. A follow-up PR splits the module back into focused files. The new bundle path preserves the important cloud requirements loader semantics where intended: account-scoped signed cache, 30 minute TTL, 5 minute refresh cadence, retry/backoff, auth recovery, and fail-closed startup loading. The cached payload changes from a single requirements TOML string to the backend-delivered bundle, and validation rejects malformed config or requirements fragments before cache write/use. --- codex-rs/Cargo.lock | 2 - codex-rs/app-server-client/src/lib.rs | 14 +- codex-rs/app-server/src/config_manager.rs | 32 +- .../src/config_manager_service_tests.rs | 35 +- codex-rs/app-server/src/in_process.rs | 10 +- codex-rs/app-server/src/lib.rs | 9 +- codex-rs/app-server/src/mcp_refresh.rs | 4 +- .../src/message_processor_tracing_tests.rs | 4 +- codex-rs/app-server/src/request_processors.rs | 4 +- .../request_processors/account_processor.rs | 4 +- .../src/request_processors/config_errors.rs | 10 +- .../thread_processor_tests.rs | 46 +- .../tests/suite/conversation_summary.rs | 4 +- .../app-server/tests/suite/v2/mcp_resource.rs | 4 +- .../tests/suite/v2/remote_thread_store.rs | 4 +- .../app-server/tests/suite/v2/thread_fork.rs | 6 +- .../app-server/tests/suite/v2/thread_read.rs | 8 +- .../tests/suite/v2/thread_resume.rs | 6 +- .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/thread_unarchive.rs | 4 +- codex-rs/backend-client/src/client.rs | 18 - codex-rs/backend-client/src/lib.rs | 1 - codex-rs/backend-client/src/types.rs | 1 - codex-rs/cloud-config/Cargo.toml | 4 +- codex-rs/cloud-config/src/lib.rs | 2160 ++++++++--------- codex-rs/config/src/cloud_config_bundle.rs | 214 ++ .../config/src/cloud_config_bundle_tests.rs | 125 + codex-rs/config/src/cloud_config_layers.rs | 45 +- .../config/src/cloud_config_layers_tests.rs | 21 + codex-rs/config/src/cloud_requirements.rs | 105 - codex-rs/config/src/config_requirements.rs | 45 +- codex-rs/config/src/lib.rs | 14 +- codex-rs/config/src/loader/README.md | 26 +- codex-rs/config/src/loader/macos.rs | 47 +- codex-rs/config/src/loader/mod.rs | 298 +-- codex-rs/config/src/loader/tests.rs | 3 - codex-rs/config/src/state.rs | 7 +- codex-rs/config/src/strict_config.rs | 1 - codex-rs/config/src/test_support.rs | 80 + codex-rs/config/src/test_support_tests.rs | 25 + codex-rs/core-plugins/src/test_support.rs | 2 - .../core/src/config/config_loader_tests.rs | 496 ++-- codex-rs/core/src/config/config_tests.rs | 418 ++-- codex-rs/core/src/config/mod.rs | 14 +- codex-rs/core/src/connectors_tests.rs | 88 +- codex-rs/core/src/network_proxy_loader.rs | 2 - codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/tests/common/lib.rs | 36 +- codex-rs/core/tests/common/test_codex.rs | 19 +- codex-rs/core/tests/suite/approvals.rs | 6 +- codex-rs/core/tests/suite/hooks.rs | 2 +- codex-rs/core/tests/suite/unified_exec.rs | 2 +- codex-rs/exec/src/lib.rs | 108 +- codex-rs/exec/src/lib_tests.rs | 1 + codex-rs/hooks/src/engine/discovery.rs | 4 - codex-rs/hooks/src/engine/mod_tests.rs | 29 +- codex-rs/tui/src/debug_config.rs | 82 +- codex-rs/tui/src/lib.rs | 172 +- codex-rs/tui/src/onboarding/auth.rs | 4 +- codex-rs/tui/src/session_archive_commands.rs | 10 +- 60 files changed, 2615 insertions(+), 2340 deletions(-) create mode 100644 codex-rs/config/src/cloud_config_bundle.rs create mode 100644 codex-rs/config/src/cloud_config_bundle_tests.rs delete mode 100644 codex-rs/config/src/cloud_requirements.rs create mode 100644 codex-rs/config/src/test_support.rs create mode 100644 codex-rs/config/src/test_support_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 27b66293e..e7bb18b5f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2356,7 +2356,6 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", - "codex-utils-absolute-path", "hmac 0.12.1", "pretty_assertions", "serde", @@ -2365,7 +2364,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 0.9.11+spec-1.1.0", "tracing", ] diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 746f0d844..c75915629 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -43,7 +43,7 @@ use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; @@ -339,8 +339,8 @@ pub struct InProcessClientStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. @@ -406,7 +406,7 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, strict_config: self.strict_config, - cloud_requirements: self.cloud_requirements, + cloud_config_bundle: self.cloud_config_bundle, thread_config_loader, feedback: self.feedback, log_db: self.log_db, @@ -1035,7 +1035,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: Some(state_db), @@ -2199,7 +2199,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, @@ -2240,7 +2240,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 11633342b..fae113154 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -1,6 +1,6 @@ use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStack; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; @@ -31,7 +31,7 @@ pub(crate) struct ConfigManager { runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: Arc>, + cloud_config_bundle: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, } @@ -42,7 +42,7 @@ impl ConfigManager { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, ) -> Self { @@ -52,7 +52,7 @@ impl ConfigManager { runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, strict_config, - cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), + cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), } @@ -73,8 +73,8 @@ impl ConfigManager { .unwrap_or_default() } - pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader { - self.cloud_requirements + pub(crate) fn current_cloud_config_bundle(&self) -> CloudConfigBundleLoader { + self.cloud_config_bundle .read() .map(|guard| guard.clone()) .unwrap_or_default() @@ -90,17 +90,17 @@ impl ConfigManager { Ok(()) } - pub(crate) fn replace_cloud_requirements_loader( + pub(crate) fn replace_cloud_config_bundle_loader( &self, auth_manager: Arc, chatgpt_base_url: String, ) { let loader = - cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); - if let Ok(mut guard) = self.cloud_requirements.write() { + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); + if let Ok(mut guard) = self.cloud_config_bundle.write() { *guard = loader; } else { - warn!("failed to update cloud requirements loader"); + warn!("failed to update cloud config bundle loader"); } } @@ -245,7 +245,7 @@ impl ConfigManager { .strict_config(self.strict_config) .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) - .cloud_requirements(self.current_cloud_requirements()) + .cloud_config_bundle(self.current_cloud_config_bundle()) .thread_config_loader(self.current_thread_config_loader()) .build() .await?; @@ -274,8 +274,8 @@ impl ConfigManager { codex_config::ConfigLoadOptions { loader_overrides: self.loader_overrides.clone(), strict_config: self.strict_config, + cloud_config_bundle: self.current_cloud_config_bundle(), }, - self.current_cloud_requirements(), thread_config_loader.as_ref(), ) .await @@ -303,14 +303,14 @@ impl ConfigManager { codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Self { Self::new( codex_home, cli_overrides, loader_overrides, /*strict_config*/ false, - cloud_requirements, + cloud_config_bundle, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ) @@ -322,7 +322,7 @@ impl ConfigManager { codex_home, Vec::new(), LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) } } diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index da2437ca1..8deae8b06 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -4,12 +4,11 @@ use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; -use codex_config::CloudRequirementsLoader; -use codex_config::FeatureRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; +use codex_config::test_support::CloudConfigBundleFixture; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -use std::collections::BTreeMap; use tempfile::tempdir; #[test] @@ -356,7 +355,7 @@ async fn read_includes_origins_and_layers() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -435,7 +434,7 @@ writable_roots = ["~/code"] tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -475,7 +474,7 @@ async fn write_value_reports_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service @@ -578,7 +577,7 @@ async fn write_value_defaults_to_selected_user_config_path() { tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service .write_value(ConfigValueWriteParams { @@ -618,7 +617,7 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service @@ -648,7 +647,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let error = service @@ -708,14 +707,12 @@ async fn write_value_rejects_feature_requirement_conflict() { tmp.path().to_path_buf(), vec![], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +"#, + ), ); let error = service @@ -765,7 +762,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() { tmp.path().to_path_buf(), cli_overrides, LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -821,7 +818,7 @@ async fn write_value_reports_managed_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index f7072d0fa..448a38985 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -77,7 +77,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; @@ -124,8 +124,8 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Loader used to fetch typed thread config sources before a thread starts. pub thread_config_loader: Arc, /// Feedback sink used by app-server/core telemetry and logs. @@ -415,7 +415,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { - warn!(error = %err, "Failed to preload config for cloud requirements"); - // TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed. + warn!(error = %err, "Failed to preload config for cloud config bundle"); + // TODO: Decide whether bootstrap config preload failures should block startup. + // If this fails, we cannot install cloud/thread config loaders, so non-strict + // startup may continue without managed cloud config. } }; let mut config_warnings = Vec::new(); diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 4ddfa3473..0996c409b 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -103,7 +103,7 @@ mod tests { use crate::extensions::thread_extensions; use async_trait::async_trait; use codex_arg0::Arg0DispatchPaths; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigContext; use codex_config::ThreadConfigLoadError; @@ -213,7 +213,7 @@ mod tests { Vec::new(), LoaderOverrides::without_managed_config_for_tests(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), loader.clone(), ); diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 94b5a1af2..771b1fd12 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -23,7 +23,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -240,7 +240,7 @@ async fn build_test_processor( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 5d8cb4d79..36008ce0b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -266,8 +266,8 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; 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 17d78fb20..8ebfc037e 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -586,7 +586,7 @@ impl AccountRequestProcessor { ) .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; self.auth_manager.reload().await; - self.config_manager.replace_cloud_requirements_loader( + self.config_manager.replace_cloud_config_bundle_loader( self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), ); @@ -645,7 +645,7 @@ impl AccountRequestProcessor { let auth_manager = thread_manager.auth_manager(); auth_manager.reload().await; config_manager - .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); + .replace_cloud_config_bundle_loader(auth_manager.clone(), chatgpt_base_url); config_manager .sync_default_client_residency_requirement() .await; 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 63fe2b3d2..7e93b63ed 100644 --- a/codex-rs/app-server/src/request_processors/config_errors.rs +++ b/codex-rs/app-server/src/request_processors/config_errors.rs @@ -1,11 +1,11 @@ use super::*; -fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { +fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBundleLoadError> { let mut current: Option<&(dyn std::error::Error + 'static)> = err .get_ref() .map(|source| source as &(dyn std::error::Error + 'static)); while let Some(source) = current { - if let Some(cloud_error) = source.downcast_ref::() { + if let Some(cloud_error) = source.downcast_ref::() { return Some(cloud_error); } current = source.source(); @@ -14,16 +14,16 @@ fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequireme } pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { - let data = cloud_requirements_load_error(err).map(|cloud_error| { + let data = cloud_config_bundle_load_error(err).map(|cloud_error| { let mut data = serde_json::json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": format!("{:?}", cloud_error.code()), "detail": cloud_error.to_string(), }); if let Some(status_code) = cloud_error.status_code() { data["statusCode"] = serde_json::json!(status_code); } - if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + if cloud_error.code() == CloudConfigBundleLoadErrorCode::Auth { data["action"] = serde_json::json!("relogin"); } data diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 8689193ce..6be022aa6 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -54,7 +54,7 @@ mod thread_processor_behavior_tests { use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ToolRequestUserInputParams; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; @@ -508,9 +508,9 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_cloud_requirements_failures_for_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + fn config_load_error_marks_cloud_config_bundle_failures_for_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, Some(401), "Your authentication session could not be refreshed automatically. Please log out and sign in again.", )); @@ -520,7 +520,7 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, @@ -535,7 +535,7 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + fn config_load_error_leaves_non_cloud_config_bundle_failures_unmarked() { let err = std::io::Error::other("required MCP servers failed to initialize"); let error = config_load_error(&err); @@ -549,11 +549,11 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, + fn config_load_error_marks_non_auth_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, /*status_code*/ None, - "Failed to load cloud requirements (workspace-managed policies).", + "Failed to load cloud config bundle (workspace-managed policies).", )); let error = config_load_error(&err); @@ -561,9 +561,29 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "RequestFailed", - "detail": "Failed to load cloud requirements (workspace-managed policies).", + "detail": "Failed to load cloud config bundle (workspace-managed policies).", + })) + ); + } + + #[test] + fn config_load_error_marks_invalid_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudConfigBundle", + "errorCode": "InvalidBundle", + "detail": "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", })) ); } @@ -595,7 +615,7 @@ mod thread_processor_behavior_tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(StaticThreadConfigLoader::new(vec![ ThreadConfigSource::Session(SessionThreadConfig { diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index d54f5edc5..05e3ad1c7 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -15,7 +15,7 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; @@ -150,7 +150,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 37b7a10d9..d8ecb8899 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -20,7 +20,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; @@ -201,7 +201,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 158ebb25d..016e4c3df 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -34,7 +34,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_core::config::ConfigBuilder; @@ -76,7 +76,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index afbe62b32..afb1ca0ed 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -510,10 +510,10 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { } #[tokio::test] -async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -594,7 +594,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( fork_err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 17c55a74a..0a99d30ad 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -41,7 +41,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_core::config::ConfigBuilder; @@ -376,7 +376,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -442,7 +442,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -528,7 +528,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 3073b382a..d18a40516 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3121,10 +3121,10 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R } #[tokio::test] -async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -3201,7 +3201,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 3c4a0dba5..7a7ed5522 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -718,10 +718,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< } #[tokio::test] -async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -788,7 +788,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 09c17bb79..84fffd19b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -22,7 +22,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; @@ -247,7 +247,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 159dba8b8..d9bdc9654 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,6 +1,5 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigBundleResponse; -use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; @@ -393,23 +392,6 @@ impl Client { self.decode_json::(&url, &ct, &body) } - /// Fetch the managed requirements file from codex-backend. - /// - /// `GET /api/codex/config/requirements` (Codex API style) or - /// `GET /wham/config/requirements` (ChatGPT backend-api style). - pub async fn get_config_requirements_file( - &self, - ) -> std::result::Result { - let url = match self.path_style { - PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url), - PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url), - }; - let req = self.http.get(&url).headers(self.headers()); - let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; - self.decode_json::(&url, &ct, &body) - .map_err(RequestError::from) - } - /// Fetch the selected cloud-managed config bundle from codex-backend. /// /// `GET /api/codex/config/bundle` (Codex API style) or diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 00b5bd369..250aac3d7 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -7,7 +7,6 @@ pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigBundleResponse; -pub use types::ConfigFileResponse; pub use types::DeliveredConfigToml; pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 38d9c6838..40daba2ce 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,5 +1,4 @@ pub use codex_backend_openapi_models::models::ConfigBundleResponse; -pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::DeliveredConfigToml; pub use codex_backend_openapi_models::models::DeliveredRequirementsToml; diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index aeba70a48..b9afaee10 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -17,14 +17,12 @@ codex-core = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } -codex-utils-absolute-path = { workspace = true } hmac = "0.12.1" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "sync", "time"] } -toml = { workspace = true } +tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tracing = { workspace = true } [dev-dependencies] diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 0beb91780..72d2b4d1c 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -1,12 +1,7 @@ -//! Cloud-hosted config requirements for Codex. +//! Cloud-hosted configuration data for Codex. //! -//! This crate fetches `requirements.toml` data from the backend as an alternative to loading it -//! from the local filesystem. It only applies to Business (aka Enterprise CBP) or Enterprise ChatGPT -//! customers. -//! -//! Fetching fails closed for eligible ChatGPT Business and Enterprise accounts. When cloud -//! requirements cannot be loaded for those accounts, Codex fails configuration loading rather than -//! continuing without them. +//! This crate owns transport, caching, and refresh behavior for cloud-delivered +//! config data. Parsing and composition remain in `codex-config`. use async_trait::async_trait; use base64::Engine; @@ -15,23 +10,30 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Utc; use codex_backend_client::Client as BackendClient; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLayers; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::CloudConfigBundleLoader; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_config::compose_requirements; use codex_config::types::AuthCredentialsStoreMode; use codex_core::util::backoff; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_protocol::account::PlanType; -use codex_utils_absolute_path::AbsolutePathBufGuard; use hmac::Hmac; use hmac::Mac; use serde::Deserialize; use serde::Serialize; use sha2::Sha256; -use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -46,24 +48,21 @@ use tokio::time::timeout; const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; -const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json"; +const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; +const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); -const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_requirements.fetch_attempt"; -const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_requirements.fetch_final"; -const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_requirements.load"; +const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; +const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; +const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = - "Failed to load cloud requirements (workspace-managed policies)."; -const CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE: &str = concat!( - "Cloud requirements (workspace-managed policies) are invalid and could not be parsed. ", - "Please contact your workspace admin." -); + "Failed to load cloud config bundle (workspace-managed policies)."; const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( "Your authentication session could not be refreshed automatically. ", "Please log out and sign in again." ); const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = - b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; + b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; @@ -100,54 +99,50 @@ enum FetchAttemptError { #[derive(Clone, Debug, Eq, Error, PartialEq)] enum CacheLoadStatus { - #[error("Skipping cloud requirements cache read because auth identity is incomplete.")] + #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] AuthIdentityIncomplete, - #[error("Cloud requirements cache file not found.")] + #[error("Cloud config bundle cache file not found.")] CacheFileNotFound, - #[error("Failed to read cloud requirements cache: {0}.")] + #[error("Failed to read cloud config bundle cache: {0}.")] CacheReadFailed(String), - #[error("Failed to parse cloud requirements cache: {0}.")] + #[error("Failed to parse cloud config bundle cache: {0}.")] CacheParseFailed(String), - #[error("Cloud requirements cache failed signature verification.")] + #[error("Cloud config bundle cache failed signature verification.")] CacheSignatureInvalid, - #[error("Ignoring cloud requirements cache because cached identity is incomplete.")] + #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] CacheIdentityIncomplete, - #[error("Ignoring cloud requirements cache for different auth identity.")] + #[error("Ignoring cloud config bundle cache for different auth identity.")] CacheIdentityMismatch, - #[error("Cloud requirements cache expired.")] + #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] + CacheVersionUnsupported(u32), + #[error("Cloud config bundle cache expired.")] CacheExpired, + #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] + CacheInvalidBundle, } #[derive(Debug, Error)] enum CloudRequirementsError { - #[error("failed to write cloud requirements cache")] + #[error("failed to write cloud config bundle cache")] CacheWrite, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CloudRequirementsCacheFile { signed_payload: CloudRequirementsCacheSignedPayload, signature: String, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CloudRequirementsCacheSignedPayload { + version: u32, cached_at: DateTime, expires_at: DateTime, chatgpt_user_id: Option, account_id: Option, - contents: Option, + bundle: CloudConfigBundle, } -impl CloudRequirementsCacheSignedPayload { - fn requirements(&self, requirements_base_dir: &Path) -> Option { - self.contents.as_deref().and_then(|contents| { - parse_cloud_requirements(contents, requirements_base_dir) - .ok() - .flatten() - }) - } -} fn sign_cache_payload(payload_bytes: &[u8]) -> Option { let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; mac.update(payload_bytes); @@ -191,19 +186,28 @@ fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool { && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) } +fn optional_bundle(bundle: CloudConfigBundle) -> Option { + if bundle.is_empty() { + None + } else { + Some(bundle) + } +} + fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { serde_json::to_vec(&payload).ok() } +/// Retrieves one cloud config bundle from the backend. +/// +/// Implementations should return the backend-selected bundle exactly as delivered and leave +/// validation, caching, and config/requirements parsing decisions to the service layer. #[async_trait] trait RequirementsFetcher: Send + Sync { - /// Returns `Ok(None)` when there are no cloud requirements for the account. - /// - /// Returning `Err` indicates cloud requirements could not be fetched. async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError>; + ) -> Result; } struct BackendRequirementsFetcher { @@ -221,20 +225,22 @@ impl RequirementsFetcher for BackendRequirementsFetcher { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { let client = BackendClient::from_auth(self.base_url.clone(), auth) .inspect_err(|err| { tracing::warn!( error = %err, - "Failed to construct backend client for cloud requirements" + "Failed to construct backend client for cloud config bundle" ); }) .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; let response = client - .get_config_requirements_file() + .get_config_bundle() .await - .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) + .inspect_err(|err| { + tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); + }) .map_err(|err| { let status_code = err.status().map(|status| status.as_u16()); if err.is_unauthorized() { @@ -247,14 +253,55 @@ impl RequirementsFetcher for BackendRequirementsFetcher { } })?; - let Some(contents) = response.contents else { - tracing::info!( - "Cloud requirements response missing contents; treating as no requirements" - ); - return Ok(None); - }; + Ok(bundle_from_response(response)) + } +} - Ok(Some(contents)) +fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { + let config_toml = response + .config_toml + .flatten() + .map(|config_toml| *config_toml) + .and_then(|config_toml| config_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(config_fragment_from_delivered) + .collect(); + let requirements_toml = response + .requirements_toml + .flatten() + .map(|requirements_toml| *requirements_toml) + .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(requirements_fragment_from_delivered) + .collect(); + + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: config_toml, + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: requirements_toml, + }, + } +} + +fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { + CloudConfigFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} + +fn requirements_fragment_from_delivered( + fragment: DeliveredTomlFragment, +) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, } } @@ -262,8 +309,8 @@ impl RequirementsFetcher for BackendRequirementsFetcher { struct CloudRequirementsService { auth_manager: Arc, fetcher: Arc, - requirements_base_dir: PathBuf, - cache_path: PathBuf, + cache_path: AbsolutePathBuf, + codex_home: AbsolutePathBuf, timeout: Duration, } @@ -274,90 +321,102 @@ impl CloudRequirementsService { codex_home: PathBuf, timeout: Duration, ) -> Self { + let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); + let cache_path = codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME); Self { auth_manager, fetcher, - requirements_base_dir: codex_home.clone(), - cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), + cache_path, + codex_home, timeout, } } async fn fetch_with_timeout( &self, - ) -> Result, CloudRequirementsLoadError> { + ) -> Result, CloudConfigBundleLoadError> { let _timer = - codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); + codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); let started_at = Instant::now(); - let fetch_result = timeout(self.timeout, self.fetch()) + let load_result = timeout(self.timeout, self.fetch()) .await .inspect_err(|_| { let message = format!( - "Timed out waiting for cloud requirements after {}s", + "Timed out waiting for cloud config bundle after {}s", self.timeout.as_secs() ); tracing::error!("{message}"); - emit_load_metric("startup", "error"); + emit_load_metric("startup", "error", /*bundle*/ None); }) .map_err(|_| { - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Timeout, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Timeout, /*status_code*/ None, format!( - "timed out waiting for cloud requirements after {}s", + "timed out waiting for cloud config bundle after {}s", self.timeout.as_secs() ), ) })?; - let result = match fetch_result { + let result = match load_result { Ok(result) => result, Err(err) => { - emit_load_metric("startup", "error"); + emit_load_metric("startup", "error", /*bundle*/ None); return Err(err); } }; match result.as_ref() { - Some(requirements) => { + Some(bundle) => { tracing::info!( elapsed_ms = started_at.elapsed().as_millis(), - requirements = ?requirements, - "Cloud requirements load completed" + config_fragments = bundle.config_toml.enterprise_managed.len(), + requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), + "Cloud config bundle load completed" ); - emit_load_metric("startup", "success"); + emit_load_metric("startup", "success", Some(bundle)); } None => { tracing::info!( elapsed_ms = started_at.elapsed().as_millis(), - "Cloud requirements load completed (none)" + "Cloud config bundle load completed (none)" ); - emit_load_metric("startup", "success"); + emit_load_metric("startup", "success", /*bundle*/ None); } } Ok(result) } - async fn fetch(&self) -> Result, CloudRequirementsLoadError> { + async fn fetch(&self) -> Result, CloudConfigBundleLoadError> { let Some(auth) = self.auth_manager.auth().await else { return Ok(None); }; if !cloud_requirements_eligible_auth(&auth) { return Ok(None); } - let (chatgpt_user_id, account_id) = auth_identity(&auth); + let (chatgpt_user_id, account_id) = auth_identity(&auth); match self .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) .await { Ok(signed_payload) => { - tracing::info!( - path = %self.cache_path.display(), - "Using cached cloud requirements" - ); - return Ok(signed_payload.requirements(&self.requirements_base_dir)); + if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { + tracing::warn!( + path = %self.cache_path.display(), + error = %err, + "Ignoring invalid cached cloud config bundle" + ); + self.log_cache_load_status(&CacheLoadStatus::CacheInvalidBundle); + } else { + tracing::info!( + path = %self.cache_path.display(), + "Using cached cloud config bundle" + ); + return Ok(optional_bundle(signed_payload.bundle)); + } } Err(cache_load_status) => { self.log_cache_load_status(&cache_load_status); @@ -371,18 +430,18 @@ impl CloudRequirementsService { &self, mut auth: CodexAuth, trigger: &'static str, - ) -> Result, CloudRequirementsLoadError> { + ) -> Result, CloudConfigBundleLoadError> { let mut attempt = 1; let mut last_status_code: Option = None; let mut auth_recovery = self.auth_manager.unauthorized_recovery(); while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let contents = match self.fetcher.fetch_requirements(&auth).await { - Ok(contents) => { + let bundle = match self.fetcher.fetch_requirements(&auth).await { + Ok(bundle) => { emit_fetch_attempt_metric( trigger, attempt, "success", /*status_code*/ None, ); - contents + bundle } Err(FetchAttemptError::Retryable(status)) => { let status_code = status.status_code(); @@ -393,7 +452,7 @@ impl CloudRequirementsService { status = ?status, attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to fetch cloud requirements; retrying" + "Failed to fetch cloud config bundle; retrying" ); sleep(backoff(attempt as u64)).await; } @@ -410,13 +469,13 @@ impl CloudRequirementsService { tracing::warn!( attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Cloud requirements request was unauthorized; attempting auth recovery" + "Cloud config bundle request was unauthorized; attempting auth recovery" ); match auth_recovery.next().await { Ok(_) => { let Some(refreshed_auth) = self.auth_manager.auth().await else { tracing::error!( - "Auth recovery succeeded but no auth is available for cloud requirements" + "Auth recovery succeeded but no auth is available for cloud config bundle" ); emit_fetch_final_metric( trigger, @@ -424,9 +483,10 @@ impl CloudRequirementsService { "auth_recovery_missing_auth", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); @@ -437,7 +497,7 @@ impl CloudRequirementsService { Err(RefreshTokenError::Permanent(failed)) => { tracing::warn!( error = %failed, - "Failed to recover from unauthorized cloud requirements request" + "Failed to recover from unauthorized cloud config bundle request" ); emit_fetch_final_metric( trigger, @@ -445,9 +505,10 @@ impl CloudRequirementsService { "auth_recovery_unrecoverable", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, failed.message, )); @@ -458,7 +519,7 @@ impl CloudRequirementsService { error = %recovery_err, attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to recover from unauthorized cloud requirements request; retrying" + "Failed to recover from unauthorized cloud config bundle request; retrying" ); sleep(backoff(attempt as u64)).await; } @@ -470,7 +531,7 @@ impl CloudRequirementsService { tracing::warn!( error = %message, - "Cloud requirements request was unauthorized and no auth recovery is available" + "Cloud config bundle request was unauthorized and no auth recovery is available" ); emit_fetch_final_metric( trigger, @@ -478,48 +539,48 @@ impl CloudRequirementsService { "auth_recovery_unavailable", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); } }; - let requirements = match contents.as_deref() { - Some(contents) => { - match parse_cloud_requirements(contents, &self.requirements_base_dir) { - Ok(requirements) => requirements, - Err(err) => { - tracing::error!(error = %err, "Failed to parse cloud requirements"); - emit_fetch_final_metric( - trigger, - "error", - "parse_error", - attempt, - last_status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Parse, - /*status_code*/ None, - format_cloud_requirements_parse_failed_message(contents, &err), - )); - } - } - } - None => None, - }; + if let Err(err) = validate_bundle(&bundle, &self.codex_home) { + emit_fetch_final_metric( + trigger, + "error", + "invalid_bundle", + attempt, + /*status_code*/ None, + /*bundle*/ None, + ); + return Err(err); + } let (chatgpt_user_id, account_id) = auth_identity(&auth); - if let Err(err) = self.save_cache(chatgpt_user_id, account_id, contents).await { - tracing::warn!(error = %err, "Failed to write cloud requirements cache"); + if let Err(err) = self + .save_cache(chatgpt_user_id, account_id, bundle.clone()) + .await + { + tracing::warn!( + error = %err, + "Failed to write cloud config bundle cache" + ); } emit_fetch_final_metric( - trigger, "success", "none", attempt, /*status_code*/ None, + trigger, + "success", + "none", + attempt, + /*status_code*/ None, + Some(&bundle), ); - return Ok(requirements); + return Ok(optional_bundle(bundle)); } emit_fetch_final_metric( @@ -528,13 +589,14 @@ impl CloudRequirementsService { "request_retry_exhausted", CLOUD_REQUIREMENTS_MAX_ATTEMPTS, last_status_code, + /*bundle*/ None, ); tracing::error!( path = %self.cache_path.display(), "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" ); - Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, last_status_code, CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, )) @@ -548,9 +610,9 @@ impl CloudRequirementsService { Ok(false) => break, Err(_) => { tracing::error!( - "Timed out refreshing cloud requirements cache from remote; keeping existing cache" + "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" ); - emit_load_metric("refresh", "error"); + emit_load_metric("refresh", "error", /*bundle*/ None); } } } @@ -565,14 +627,14 @@ impl CloudRequirementsService { } match self.fetch_with_retries(auth, "refresh").await { - Ok(_) => emit_load_metric("refresh", "success"), + Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), Err(err) => { tracing::error!( path = %self.cache_path.display(), error = %err, - "Failed to refresh cloud requirements cache from remote" + "Failed to refresh cloud config bundle cache from remote" ); - emit_load_metric("refresh", "error"); + emit_load_metric("refresh", "error", /*bundle*/ None); } } true @@ -614,6 +676,11 @@ impl CloudRequirementsService { if !verify_cache_signature(&payload_bytes, &cache_file.signature) { return Err(CacheLoadStatus::CacheSignatureInvalid); } + if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { + return Err(CacheLoadStatus::CacheVersionUnsupported( + cache_file.signed_payload.version, + )); + } let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( cache_file.signed_payload.chatgpt_user_id.as_deref(), @@ -656,7 +723,7 @@ impl CloudRequirementsService { &self, chatgpt_user_id: Option, account_id: Option, - contents: Option, + bundle: CloudConfigBundle, ) -> Result<(), CloudRequirementsError> { let now = Utc::now(); let expires_at = now @@ -666,11 +733,12 @@ impl CloudRequirementsService { ) .ok_or(CloudRequirementsError::CacheWrite)?; let signed_payload = CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, cached_at: now, expires_at, chatgpt_user_id, account_id, - contents, + bundle, }; let payload_bytes = cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; @@ -694,11 +762,11 @@ impl CloudRequirementsService { } } -pub fn cloud_requirements_loader( +pub fn cloud_config_bundle_loader( auth_manager: Arc, chatgpt_base_url: String, codex_home: PathBuf, -) -> CloudRequirementsLoader { +) -> CloudConfigBundleLoader { let service = CloudRequirementsService::new( auth_manager, Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), @@ -710,30 +778,30 @@ pub fn cloud_requirements_loader( let refresh_task = tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { - tracing::warn!("cloud requirements refresher task slot was poisoned"); + tracing::warn!("cloud config bundle refresher task slot was poisoned"); err.into_inner() }); if let Some(existing_task) = refresher_guard.replace(refresh_task) { existing_task.abort(); } - CloudRequirementsLoader::new(async move { + CloudConfigBundleLoader::new(async move { task.await.map_err(|err| { - tracing::error!(error = %err, "Cloud requirements task failed"); - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Internal, + tracing::error!(error = %err, "Cloud config bundle task failed"); + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Internal, /*status_code*/ None, - format!("cloud requirements load failed: {err}"), + format!("cloud config bundle load failed: {err}"), ) })? }) } -pub async fn cloud_requirements_loader_for_storage( +pub async fn cloud_config_bundle_loader_for_storage( codex_home: PathBuf, enable_codex_api_key_env: bool, credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: String, -) -> CloudRequirementsLoader { +) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( codex_home.clone(), enable_codex_api_key_env, @@ -741,31 +809,35 @@ pub async fn cloud_requirements_loader_for_storage( Some(chatgpt_base_url.clone()), ) .await; - cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) } -fn parse_cloud_requirements( - contents: &str, - requirements_base_dir: &Path, -) -> Result, toml::de::Error> { - if contents.trim().is_empty() { - return Ok(None); - } +fn validate_bundle( + bundle: &CloudConfigBundle, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigBundleLoadError> { + let bundle_layers = + CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + let CloudConfigBundleLayers { + enterprise_managed_config: _, + enterprise_managed_requirements, + } = bundle_layers; - let _guard = AbsolutePathBufGuard::new(requirements_base_dir); - let requirements: ConfigRequirementsToml = toml::from_str(contents)?; - if requirements.is_empty() { - Ok(None) - } else { - Ok(Some(requirements)) - } -} + compose_requirements(enterprise_managed_requirements).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; -fn format_cloud_requirements_parse_failed_message( - _contents: &str, - err: &toml::de::Error, -) -> String { - format!("{CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE}\n\nDetails:\n{err}") + Ok(()) } fn emit_fetch_attempt_metric( @@ -793,6 +865,7 @@ fn emit_fetch_final_metric( reason: &str, attempt_count: usize, status_code: Option, + bundle: Option<&CloudConfigBundle>, ) { let attempt_count_tag = attempt_count.to_string(); let status_code_tag = status_code_tag(status_code); @@ -804,20 +877,43 @@ fn emit_fetch_final_metric( ("reason", reason.to_string()), ("attempt_count", attempt_count_tag), ("status_code", status_code_tag), + ("bundle_shape", bundle_shape_tag(bundle)), ], ); } -fn emit_load_metric(trigger: &str, outcome: &str) { +fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { emit_metric( CLOUD_REQUIREMENTS_LOAD_METRIC, vec![ ("trigger", trigger.to_string()), ("outcome", outcome.to_string()), + ("bundle_shape", bundle_shape_tag(bundle)), ], ); } +fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { + let Some(bundle) = bundle else { + return "none".to_string(); + }; + + let mut sources = Vec::new(); + if !bundle.config_toml.enterprise_managed.is_empty() { + sources.push("enterprise_config"); + } + if !bundle.requirements_toml.enterprise_managed.is_empty() { + sources.push("enterprise_requirements"); + } + + if sources.is_empty() { + "empty".to_string() + } else { + sources.sort_unstable(); + sources.join(",") + } +} + fn status_code_tag(status_code: Option) -> String { status_code .map(|status_code| status_code.to_string()) @@ -839,43 +935,22 @@ mod tests { use super::*; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_config::AppToolApproval; + use codex_backend_client::ConfigBundleResponse; + use codex_backend_client::DeliveredTomlFragment; + use codex_config::CloudConfigFragment; + use codex_config::CloudConfigTomlBundle; + use codex_config::CloudRequirementsFragment; + use codex_config::CloudRequirementsTomlBundle; use codex_config::types::AuthCredentialsStoreMode; - use codex_login::auth::AgentIdentityAuth; - use codex_login::auth::AgentIdentityAuthRecord; - use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use serde_json::json; - use std::collections::BTreeMap; use std::collections::VecDeque; - use std::ffi::OsString; use std::future::pending; - use std::io::Read; - use std::io::Write; - use std::net::TcpListener; use std::path::Path; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - use std::thread; - use tempfile::TempDir; use tempfile::tempdir; - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; Ok(()) @@ -928,6 +1003,11 @@ mod tests { ) } + async fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) + .await + } + fn chatgpt_auth_json( plan_type: &str, chatgpt_user_id: Option<&str>, @@ -1004,55 +1084,44 @@ mod tests { auth_json } - struct ManagedAuthContext { - _home: TempDir, - manager: Arc, - } - - async fn managed_auth_context( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - ) -> ManagedAuthContext { - let home = tempdir().expect("tempdir"); - write_auth_json( - home.path(), - chatgpt_auth_json( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - ), - ) - .expect("write auth"); - ManagedAuthContext { - manager: Arc::new( - AuthManager::new( - home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ), - _home: home, + fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, } } - async fn auth_manager_with_plan(plan_type: &str) -> Arc { - auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) - .await + fn test_config_fragment() -> CloudConfigFragment { + CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + } } - fn parse_for_fetch(contents: Option<&str>) -> Option { - contents.and_then(|contents| { - parse_cloud_requirements(contents, &std::env::temp_dir()) - .ok() - .flatten() - }) + fn test_requirements_fragment() -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + } + } + + fn invalid_config_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_invalid".to_string(), + name: "Invalid config".to_string(), + contents: "model = [".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + } } fn request_error() -> FetchAttemptError { @@ -1060,39 +1129,50 @@ mod tests { } struct StaticFetcher { - contents: Option, + bundle: CloudConfigBundle, + request_count: AtomicUsize, } - #[async_trait::async_trait] + impl StaticFetcher { + fn new(bundle: CloudConfigBundle) -> Self { + Self { + bundle, + request_count: AtomicUsize::new(0), + } + } + } + + #[async_trait] impl RequirementsFetcher for StaticFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - Ok(self.contents.clone()) + ) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Ok(self.bundle.clone()) } } struct PendingFetcher; - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for PendingFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { pending::<()>().await; - Ok(None) + Ok(CloudConfigBundle::default()) } } struct SequenceFetcher { - responses: tokio::sync::Mutex, FetchAttemptError>>>, + responses: tokio::sync::Mutex>>, request_count: AtomicUsize, } impl SequenceFetcher { - fn new(responses: Vec, FetchAttemptError>>) -> Self { + fn new(responses: Vec>) -> Self { Self { responses: tokio::sync::Mutex::new(VecDeque::from(responses)), request_count: AtomicUsize::new(0), @@ -1100,40 +1180,42 @@ mod tests { } } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for SequenceFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); let mut responses = self.responses.lock().await; - responses.pop_front().unwrap_or(Ok(None)) + responses + .pop_front() + .unwrap_or_else(|| Ok(CloudConfigBundle::default())) } } struct TokenFetcher { expected_token: String, - contents: String, + bundle: CloudConfigBundle, request_count: AtomicUsize, } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for TokenFetcher { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); if matches!( auth.get_token().as_deref(), Ok(token) if token == self.expected_token.as_str() ) { - Ok(Some(self.contents.clone())) + Ok(self.bundle.clone()) } else { Err(FetchAttemptError::Unauthorized { status_code: Some(401), - message: "GET /config/requirements failed: 401".to_string(), + message: "GET /config/bundle failed: 401".to_string(), }) } } @@ -1144,12 +1226,12 @@ mod tests { request_count: AtomicUsize, } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for UnauthorizedFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); Err(FetchAttemptError::Unauthorized { status_code: Some(401), @@ -1158,389 +1240,293 @@ mod tests { } } + #[test] + fn bundle_shape_tag_describes_sorted_enterprise_sources() { + assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle::default())), + "empty" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + })), + "enterprise_config" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_requirements" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_config,enterprise_requirements" + ); + } + #[tokio::test] async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let auth_manager = auth_manager_with_api_key().await; + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager, - Arc::new(StaticFetcher { contents: None }), + auth_manager_with_api_key().await, + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); + + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("pro").await, - Arc::new(StaticFetcher { contents: None }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); + + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("self_serve_business_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_allows_business_plan() { + let bundle = test_bundle(); let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(bundle.clone())); let service = CloudRequirementsService::new( auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) + + assert_eq!(service.fetch().await, Ok(Some(bundle.clone()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() ); } #[tokio::test] - async fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind task registration server"); - let addr = listener - .local_addr() - .expect("task registration server addr"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept task registration request"); - let mut request = [0; 4096]; - let _ = stream - .read(&mut request) - .expect("read task registration request"); - let body = r#"{"task_id":"task-123"}"#; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ) - .expect("write task registration response"); - }); - let record = AgentIdentityAuthRecord { - agent_runtime_id: "agent-runtime-123".to_string(), - agent_private_key: "MC4CAQAwBQYDK2VwBCIEIDQg14jybCLydjHQwXeBzsDM7oB6BSAenodx6oCovQ/D" - .to_string(), - account_id: "account-12345".to_string(), - chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), - plan_type: PlanType::Business, - chatgpt_account_is_fedramp: false, - }; - let authapi_base_url = format!("http://{addr}/backend-api"); - let original_authapi_base_url = std::env::var_os("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"); - unsafe { - std::env::set_var("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url); - } - let _authapi_guard = EnvVarGuard { - key: "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", - original: original_authapi_base_url, - }; - let auth = AgentIdentityAuth::load(record) - .await - .map(CodexAuth::AgentIdentity) - .expect("agent identity auth"); - server.join().expect("task registration server joined"); + async fn fetch_requirements_rejects_invalid_remote_bundle_before_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(invalid_config_bundle())); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); - assert!(cloud_requirements_eligible_auth(&auth)); + let err = service + .fetch() + .await + .expect_err("invalid remote bundle should fail closed"); + + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); + assert!(err.to_string().contains("invalid cloud config bundle")); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + !codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() + ); + } + + #[tokio::test] + async fn fetch_requirements_ignores_invalid_cache_and_refetches() { + let codex_home = tempdir().expect("tempdir"); + let replacement_bundle = test_bundle(); + let fetcher = Arc::new(StaticFetcher::new(replacement_bundle.clone())); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + service + .save_cache( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + invalid_config_bundle(), + ) + .await + .expect("write invalid cache"); + + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle.clone()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await + .expect("load refreshed cache") + .bundle, + replacement_bundle + ); } #[tokio::test] async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise_cbp_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("hc").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] - async fn fetch_cloud_requirements_handles_missing_contents() { - let result = parse_for_fetch(/*contents*/ None); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_empty_contents() { - let result = parse_for_fetch(Some(" ")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_invalid_toml() { - let result = parse_for_fetch(Some("not = [")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_empty_requirements() { - let result = parse_for_fetch(Some("# comment")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_valid_toml() { - let result = parse_for_fetch(Some("allowed_approval_policies = [\"never\"]")); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_resolves_relative_deny_read_globs_from_codex_home() { + async fn fetch_requirements_empty_response_is_success_and_cached() { let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(CloudConfigBundle::default())); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise").await, - Arc::new(StaticFetcher { - contents: Some( - r#" -[permissions.filesystem] -deny_read = ["./sensitive/**/*.txt"] -"# - .to_string(), - ), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let deny_read = format!("{}/sensitive/**/*.txt", codex_home.path().display()); - let expected = toml::from_str::(&format!( - r#" -[permissions.filesystem] -deny_read = [{deny_read:?}] -"# - )) - .expect("parse expected cloud requirements"); - assert_eq!(service.fetch().await, Ok(Some(expected))); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_apps_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] -enabled = false -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }) + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() ); } #[tokio::test] - async fn fetch_cloud_requirements_parses_apps_tool_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa.tools."calendar/list_events"] -approval_mode = "approve" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - enabled: None, - tools: Some(codex_config::AppToolsRequirementsToml { - tools: BTreeMap::from([( - "calendar/list_events".to_string(), - codex_config::AppToolRequirementToml { - approval_mode: Some(AppToolApproval::Approve), - }, - )]), - }), - }, - )]), - }), - ..Default::default() - }) + async fn fetch_cloud_requirements_uses_cache_when_valid() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher::new(bundle.clone())), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!(service.fetch().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn fetch_cloud_requirements_parses_plugin_mcp_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[plugins."sample@test".mcp_servers.sample.identity] -command = "sample-mcp" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - codex_config::McpServerRequirement { - identity: codex_config::McpServerIdentity::Command { - command: "sample-mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }) + async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-12345"), + Some("account-12345"), + ) + .await, + Arc::new(StaticFetcher::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, ); + let _ = prime_service.fetch().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-99999"), + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_times_out() { - let auth_manager = auth_manager_with_plan("enterprise").await; let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager, + auth_manager_with_plan("enterprise").await, Arc::new(PendingFetcher), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1548,11 +1534,11 @@ command = "sample-mcp" let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; - let result = handle.await.expect("cloud requirements task"); - let err = result.expect_err("cloud requirements timeout should fail closed"); + let result = handle.await.expect("cloud config bundle task"); + let err = result.expect_err("cloud config bundle timeout should fail closed"); assert!( err.to_string() - .contains("timed out waiting for cloud requirements") + .contains("timed out waiting for cloud config bundle") ); } @@ -1560,7 +1546,7 @@ command = "sample-mcp" async fn fetch_cloud_requirements_retries_until_success() { let fetcher = Arc::new(SequenceFetcher::new(vec![ Err(request_error()), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), + Ok(test_bundle()), ])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( @@ -1574,31 +1560,7 @@ command = "sample-mcp" tokio::task::yield_now().await; tokio::time::advance(Duration::from_secs(1)).await; - assert_eq!( - handle.await.expect("cloud requirements task"), - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } @@ -1641,49 +1603,20 @@ command = "sample-mcp" ), ) .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } @@ -1724,77 +1657,57 @@ command = "sample-mcp" ), ) .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-99999".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) + service + .load_cache(Some("user-99999"), Some("account-12345")) + .await + .expect("load cache") + .bundle, + test_bundle() ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } #[tokio::test] async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { - let auth = managed_auth_context( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ) - .await; + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), chatgpt_auth_json( "enterprise", Some("user-12345"), @@ -1804,14 +1717,13 @@ command = "sample-mcp" ), ) .expect("write mismatched auth"); - let fetcher = Arc::new(UnauthorizedFetcher { - message: "GET /config/requirements failed: 401".to_string(), + message: "GET /config/bundle failed: 401".to_string(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1820,10 +1732,14 @@ command = "sample-mcp" let err = service .fetch() .await - .expect_err("cloud requirements should surface auth recovery errors"); + .expect_err("cloud config bundle should surface auth recovery errors"); assert_eq!( - err.to_string(), - "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again." + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", + ) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -1856,7 +1772,7 @@ command = "sample-mcp" let fetcher = Arc::new(UnauthorizedFetcher { message: - "GET https://chatgpt.com/backend-api/wham/config/requirements failed: 401; content-type=text/html; body=nope" + "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" .to_string(), request_count: AtomicUsize::new(0), }); @@ -1871,168 +1787,16 @@ command = "sample-mcp" let err = service .fetch() .await - .expect_err("cloud requirements should fail closed"); + .expect_err("cloud config bundle should fail closed"); assert_eq!( - err.to_string(), - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE - ); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Auth); - assert_eq!(err.status_code(), Some(401)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parse_error_does_not_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("not = [".to_string())), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("parse error should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("Details:")); - assert!(err_text.contains("not = [")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_invalid_enum_value_surfaces_field_name() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"definitely-not-valid\"]".to_string(), - ))])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher, - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("invalid enum value should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("allowed_approval_policies")); - assert!(err_text.contains("definitely-not-valid")); - assert!(err_text.contains("unknown variant")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - } - - #[tokio::test] - async fn fetch_cloud_requirements_uses_cache_when_valid() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_cache_when_identity_is_incomplete() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - /*chatgpt_user_id*/ None, - Some("account-12345"), + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!(cache_file.signed_payload.chatgpt_user_id, None); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] @@ -2040,17 +1804,23 @@ command = "sample-mcp" let codex_home = tempdir().expect("tempdir"); let prime_service = CloudRequirementsService::new( auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + Arc::new(StaticFetcher::new(test_bundle())), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); let _ = prime_service.fetch().await; - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); let service = CloudRequirementsService::new( auth_manager_with_plan_and_identity( "business", @@ -2063,306 +1833,7 @@ command = "sample-mcp" CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-12345"), - Some("account-12345"), - ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-99999"), - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_tampered_cache() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let mut cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(&path).expect("read cache")) - .expect("parse cache"); - cache_file.signed_payload.contents = - Some("allowed_approval_policies = [\"on-request\"]".to_string()); - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_expired_cache() { - let codex_home = tempdir().expect("tempdir"); - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file = CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload { - cached_at: Utc::now(), - expires_at: Utc::now() - ChronoDuration::seconds(1), - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - contents: Some("allowed_approval_policies = [\"on-request\"]".to_string()), - }, - signature: String::new(), - }; - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload"); - let signature = sign_cache_payload(&payload_bytes).expect("sign payload"); - let cache_file = CloudRequirementsCacheFile { - signature, - ..cache_file - }; - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_signed_cache() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let _ = service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert!( - cache_file.signed_payload.expires_at - <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) - ); - assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); - assert!(cache_file.signed_payload.cached_at <= Utc::now()); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-12345".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) - ); - assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .and_then(|contents| { - parse_cloud_requirements(contents, codex_home.path()) - .ok() - .flatten() - }), - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"); - assert!(verify_cache_signature( - &payload_bytes, - &cache_file.signature - )); - } - - #[tokio::test] - async fn fetch_cloud_requirements_none_is_success_without_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(None), Err(request_error())])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -2387,10 +1858,10 @@ command = "sample-mcp" let err = handle .await - .expect("cloud requirements task") - .expect_err("cloud requirements retry exhaustion should fail closed"); + .expect("cloud config bundle task") + .expect_err("cloud config bundle retry exhaustion should fail closed"); assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); assert_eq!( fetcher.request_count.load(Ordering::SeqCst), CLOUD_REQUIREMENTS_MAX_ATTEMPTS @@ -2398,13 +1869,21 @@ command = "sample-mcp" } #[tokio::test] - async fn refresh_from_remote_updates_cached_cloud_requirements() { + async fn refresh_from_remote_updates_cached_bundle() { + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; let codex_home = tempdir().expect("tempdir"); let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - )), + Ok(test_bundle()), + Ok(replacement_bundle.clone()), ])); let service = CloudRequirementsService::new( auth_manager_with_plan("business").await, @@ -2413,70 +1892,331 @@ command = "sample-mcp" CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert!(service.refresh_cache().await); - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); + let signed_payload = service + .load_cache(Some("user-12345"), Some("account-12345")) + .await + .expect("load cache"); + assert_eq!(signed_payload.bundle, replacement_bundle); + } + + #[test] + fn bundle_response_conversion_preserves_fragment_order() { + let response = ConfigBundleResponse { + config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { + enterprise_managed: Some(Some(vec![ + DeliveredTomlFragment::new( + "cfg_high".to_string(), + "High config".to_string(), + "model = \"high\"".to_string(), + ), + DeliveredTomlFragment::new( + "cfg_low".to_string(), + "Low config".to_string(), + "model = \"low\"".to_string(), + ), + ])), + }))), + requirements_toml: Some(Some(Box::new( + codex_backend_client::DeliveredRequirementsToml { + enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( + "req_high".to_string(), + "High requirements".to_string(), + "allowed_approval_policies = [\"never\"]".to_string(), + )])), + }, + ))), + }; + assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .and_then(|contents| { - parse_cloud_requirements(contents, codex_home.path()) - .ok() - .flatten() - }), - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) + bundle_from_response(response), + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + ); + } + + #[test] + fn bundle_response_conversion_treats_missing_sections_as_empty() { + assert_eq!( + bundle_from_response(ConfigBundleResponse::new()), + CloudConfigBundle::default() + ); + } +} + +#[cfg(test)] +mod cache_tests { + use super::*; + use codex_config::CloudConfigFragment; + use codex_config::CloudConfigTomlBundle; + use codex_config::CloudRequirementsFragment; + use codex_config::CloudRequirementsTomlBundle; + use pretty_assertions::assert_eq; + use std::path::Path; + use tempfile::tempdir; + + fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + } + + struct NeverFetcher; + + #[async_trait] + impl RequirementsFetcher for NeverFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result { + panic!("cache tests should not fetch from remote"); + } + } + + async fn create_test_service(codex_home: &Path) -> CloudRequirementsService { + let auth_home = tempdir().expect("tempdir"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + CloudRequirementsService::new( + auth_manager, + Arc::new(NeverFetcher), + codex_home.to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ) + } + + fn signed_cache_file( + signed_payload: CloudRequirementsCacheSignedPayload, + ) -> CloudRequirementsCacheFile { + let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); + CloudRequirementsCacheFile { + signature: sign_cache_payload(&payload_bytes).expect("signature"), + signed_payload, + } + } + + fn valid_signed_payload() -> CloudRequirementsCacheSignedPayload { + let cached_at = Utc::now(); + CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at, + expires_at: cached_at + ChronoDuration::minutes(30), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle: test_bundle(), + } + } + + fn write_cache_file(cache_path: &Path, cache_file: &CloudRequirementsCacheFile) { + std::fs::write( + cache_path, + serde_json::to_vec_pretty(cache_file).expect("serialize cache"), + ) + .expect("write cache"); + } + + #[tokio::test] + async fn save_writes_signed_payload_and_loads_for_matching_identity() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let bundle = test_bundle(); + + service + .save_cache( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + bundle.clone(), + ) + .await + .expect("save cache"); + + let cache_file: CloudRequirementsCacheFile = + serde_json::from_slice(&std::fs::read(&service.cache_path).expect("read cache")) + .expect("parse cache"); + assert!( + cache_file.signed_payload.expires_at + <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) + ); + assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); + assert_eq!( + cache_file, + signed_cache_file(CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: cache_file.signed_payload.cached_at, + expires_at: cache_file.signed_payload.expires_at, + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle, + }) + ); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Ok(cache_file.signed_payload) + ); + } + + #[tokio::test] + async fn load_rejects_missing_request_identity_before_reading_cache_file() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + + assert_eq!( + service + .load_cache(/*chatgpt_user_id*/ None, Some("account-12345")) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + assert_eq!( + service + .load_cache(Some("user-12345"), /*account_id*/ None) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + } + + #[tokio::test] + async fn load_reports_missing_and_malformed_cache_files() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheFileNotFound) + ); + + std::fs::write(&service.cache_path, "{").expect("write malformed cache"); + assert!(matches!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheParseFailed(_)) + )); + } + + #[tokio::test] + async fn load_rejects_tampered_payload() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut cache_file = signed_cache_file(valid_signed_payload()); + cache_file + .signed_payload + .bundle + .requirements_toml + .enterprise_managed[0] + .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); + write_cache_file(&service.cache_path, &cache_file); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheSignatureInvalid) + ); + } + + #[tokio::test] + async fn load_rejects_cache_for_incomplete_or_different_identity() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let cache_file = signed_cache_file(valid_signed_payload()); + write_cache_file(&service.cache_path, &cache_file); + + assert_eq!( + service + .load_cache(Some("user-99999"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheIdentityMismatch) + ); + + let mut signed_payload = valid_signed_payload(); + signed_payload.chatgpt_user_id = None; + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheIdentityIncomplete) + ); + } + + #[tokio::test] + async fn load_rejects_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut signed_payload = valid_signed_payload(); + signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheExpired) + ); + } + + #[tokio::test] + async fn load_rejects_unsupported_cache_version() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut signed_payload = valid_signed_payload(); + signed_payload.version = 2; + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheVersionUnsupported(2)) ); } } diff --git a/codex-rs/config/src/cloud_config_bundle.rs b/codex-rs/config/src/cloud_config_bundle.rs new file mode 100644 index 000000000..cfece32a0 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle.rs @@ -0,0 +1,214 @@ +//! Cloud config bundle domain model and shared in-memory loader. +//! +//! The backend bundle groups cloud-delivered config and requirements fragments +//! by source bucket. `CloudConfigBundleLayers` converts those raw buckets into +//! layer entries while preserving each bucket's insertion semantics. + +use crate::CloudConfigFragment; +use crate::ConfigLayerEntry; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::cloud_config_layers::CloudConfigLayerError; +use crate::cloud_config_layers::cloud_config_layers_from_fragments_strict; +use crate::cloud_config_layers_from_fragments; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::future::Future; +use thiserror::Error; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigBundle { + pub config_toml: CloudConfigTomlBundle, + pub requirements_toml: CloudRequirementsTomlBundle, +} + +impl CloudConfigBundle { + pub fn is_empty(&self) -> bool { + let CloudConfigBundle { + config_toml, + requirements_toml, + } = self; + let CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + } = config_toml; + let CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + } = requirements_toml; + + config_enterprise_managed.is_empty() && requirements_enterprise_managed.is_empty() + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +/// Cloud config bundle converted into semantic layer buckets. +/// +/// This is not a final config stack. Callers still decide where each bucket is +/// inserted relative to local/system/user layers. +#[derive(Clone, Debug)] +pub struct CloudConfigBundleLayers { + /// Enterprise-managed config layers in `ConfigLayerStack` order. + pub enterprise_managed_config: Vec, + /// Enterprise-managed requirements layers in requirements layer merge order. + pub enterprise_managed_requirements: Vec, +} + +impl CloudConfigBundleLayers { + pub fn from_bundle( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ false) + } + + pub fn from_bundle_strict_config( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ true) + } + + fn from_bundle_impl( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + strict_config: bool, + ) -> Result { + // Keep this destructuring exhaustive so adding a new bundle bucket forces + // an explicit choice about how it becomes layer data. + let CloudConfigBundle { + config_toml: + CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + }, + requirements_toml: + CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + }, + } = bundle; + + let enterprise_managed_config = if strict_config { + cloud_config_layers_from_fragments_strict(config_enterprise_managed, base_dir)? + } else { + cloud_config_layers_from_fragments(config_enterprise_managed, base_dir)? + }; + + let mut enterprise_managed_requirements = requirements_enterprise_managed + .into_iter() + .map(|fragment| { + RequirementsLayerEntry::from_toml( + RequirementSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + fragment.contents, + ) + .with_base_dir(base_dir.clone()) + }) + .collect::>(); + // Bundle fragments arrive highest-priority first, while requirements + // layers are merged lowest-priority to highest-priority. + enterprise_managed_requirements.reverse(); + + Ok(Self { + enterprise_managed_config, + enterprise_managed_requirements, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloudConfigBundleLoadErrorCode { + Auth, + Timeout, + RequestFailed, + InvalidBundle, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct CloudConfigBundleLoadError { + code: CloudConfigBundleLoadErrorCode, + message: String, + status_code: Option, +} + +impl CloudConfigBundleLoadError { + pub fn new( + code: CloudConfigBundleLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> CloudConfigBundleLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +#[derive(Clone)] +pub struct CloudConfigBundleLoader { + fut: Shared, CloudConfigBundleLoadError>>>, +} + +impl CloudConfigBundleLoader { + pub fn new(fut: F) -> Self + where + F: Future, CloudConfigBundleLoadError>> + + Send + + 'static, + { + Self { + fut: fut.boxed().shared(), + } + } + + pub async fn get(&self) -> Result, CloudConfigBundleLoadError> { + self.fut.clone().await + } +} + +impl fmt::Debug for CloudConfigBundleLoader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CloudConfigBundleLoader").finish() + } +} + +impl Default for CloudConfigBundleLoader { + fn default() -> Self { + Self::new(async { Ok(None) }) + } +} + +#[cfg(test)] +#[path = "cloud_config_bundle_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/cloud_config_bundle_tests.rs b/codex-rs/config/src/cloud_config_bundle_tests.rs new file mode 100644 index 000000000..714aca6bc --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle_tests.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::ConfigLayerSource; +use crate::ConfigRequirementsToml; +use crate::compose_requirements; +use codex_protocol::protocol::AskForApproval; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +#[tokio::test] +async fn shared_future_runs_once() { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let loader = CloudConfigBundleLoader::new(async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Ok(Some(CloudConfigBundle::default())) + }); + + let (first, second) = tokio::join!(loader.get(), loader.get()); + assert_eq!(first, second); + assert_eq!(counter.load(Ordering::SeqCst), 1); +} + +#[test] +fn bundle_layers_preserve_enterprise_managed_bucket_order() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let layers = CloudConfigBundleLayers::from_bundle( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![ + CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }, + CloudRequirementsFragment { + id: "req_low".to_string(), + name: "Low requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }, + ], + }, + }, + &base_dir, + ) + .expect("bundle should be converted into layers"); + + assert_eq!( + layers + .enterprise_managed_config + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::EnterpriseManaged { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_high".to_string(), + name: "High config".to_string(), + }, + ] + ); + assert_eq!( + compose_requirements(layers.enterprise_managed_requirements) + .expect("requirements should compose") + .expect("requirements should be present") + .into_toml(), + ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + } + ); +} + +#[test] +fn bundle_layers_can_strict_validate_enterprise_managed_config() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let err = CloudConfigBundleLayers::from_bundle_strict_config( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + contents: "unknown_key = true".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: Vec::new(), + }, + }, + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: crate::CloudConfigFragmentSource { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs index bdb11631e..f6fcd40b4 100644 --- a/codex-rs/config/src/cloud_config_layers.rs +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -7,8 +7,13 @@ use crate::ConfigLayerEntry; use crate::ConfigLayerSource; use crate::TomlValue; +use crate::config_toml::ConfigToml; use crate::loader::resolve_relative_paths_in_config_toml; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::Deserialize; +use serde::Serialize; use std::fmt; use std::io; use thiserror::Error; @@ -18,7 +23,7 @@ use thiserror::Error; /// The bundle orders fragments from highest precedence to lowest precedence. /// This module returns config layers in stack order, so callers can append the /// result between system and user config without re-sorting. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CloudConfigFragment { pub id: String, pub name: String, @@ -63,6 +68,21 @@ pub enum CloudConfigLayerError { pub fn cloud_config_layers_from_fragments( fragments: impl IntoIterator, base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false) +} + +pub(crate) fn cloud_config_layers_from_fragments_strict( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true) +} + +fn cloud_config_layers_from_fragments_impl( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, + strict_config: bool, ) -> Result, CloudConfigLayerError> { let mut layers = Vec::new(); for fragment in fragments { @@ -73,6 +93,9 @@ pub fn cloud_config_layers_from_fragments( fragment: source_ref.clone(), message: err.to_string(), })?; + if strict_config { + validate_fragment_strictly(&source_ref, &raw_toml, &value, base_dir)?; + } let resolved = resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| { CloudConfigLayerError::Invalid { @@ -97,6 +120,26 @@ pub fn cloud_config_layers_from_fragments( Ok(layers) } +fn validate_fragment_strictly( + source_ref: &CloudConfigFragmentSource, + raw_toml: &str, + value: &TomlValue, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigLayerError> { + let _guard = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(&source_ref.to_string(), raw_toml, value.clone()) + { + return Err(CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: config_error.message, + }); + } + + Ok(()) +} + impl From for io::Error { fn from(error: CloudConfigLayerError) -> Self { io::Error::new(io::ErrorKind::InvalidData, error) diff --git a/codex-rs/config/src/cloud_config_layers_tests.rs b/codex-rs/config/src/cloud_config_layers_tests.rs index c7f685fa6..c2bc7383b 100644 --- a/codex-rs/config/src/cloud_config_layers_tests.rs +++ b/codex-rs/config/src/cloud_config_layers_tests.rs @@ -58,6 +58,27 @@ fn layers_are_returned_in_stack_order() { ); } +#[test] +fn strict_layers_reject_unknown_config_fields() { + let base_dir = base_dir(); + let err = cloud_config_layers_from_fragments_strict( + vec![fragment("strict", "Strict layer", "unknown_key = true")], + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: CloudConfigFragmentSource { + id: "strict".to_string(), + name: "Strict layer".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} + #[test] fn enterprise_layers_precede_user_and_override_system() { let base_dir = base_dir(); diff --git a/codex-rs/config/src/cloud_requirements.rs b/codex-rs/config/src/cloud_requirements.rs deleted file mode 100644 index 85b904824..000000000 --- a/codex-rs/config/src/cloud_requirements.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::config_requirements::ConfigRequirementsToml; -use futures::future::BoxFuture; -use futures::future::FutureExt; -use futures::future::Shared; -use std::fmt; -use std::future::Future; -use thiserror::Error; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CloudRequirementsLoadErrorCode { - Auth, - Timeout, - Parse, - RequestFailed, - Internal, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error("{message}")] -pub struct CloudRequirementsLoadError { - code: CloudRequirementsLoadErrorCode, - message: String, - status_code: Option, -} - -impl CloudRequirementsLoadError { - pub fn new( - code: CloudRequirementsLoadErrorCode, - status_code: Option, - message: impl Into, - ) -> Self { - Self { - code, - message: message.into(), - status_code, - } - } - - pub fn code(&self) -> CloudRequirementsLoadErrorCode { - self.code - } - - pub fn status_code(&self) -> Option { - self.status_code - } -} - -#[derive(Clone)] -pub struct CloudRequirementsLoader { - fut: Shared< - BoxFuture<'static, Result, CloudRequirementsLoadError>>, - >, -} - -impl CloudRequirementsLoader { - pub fn new(fut: F) -> Self - where - F: Future, CloudRequirementsLoadError>> - + Send - + 'static, - { - Self { - fut: fut.boxed().shared(), - } - } - - pub async fn get(&self) -> Result, CloudRequirementsLoadError> { - self.fut.clone().await - } -} - -impl fmt::Debug for CloudRequirementsLoader { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CloudRequirementsLoader").finish() - } -} - -impl Default for CloudRequirementsLoader { - fn default() -> Self { - Self::new(async { Ok(None) }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::sync::Arc; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - - #[tokio::test] - async fn shared_future_runs_once() { - let counter = Arc::new(AtomicUsize::new(0)); - let counter_clone = Arc::clone(&counter); - let loader = CloudRequirementsLoader::new(async move { - counter_clone.fetch_add(1, Ordering::SeqCst); - Ok(Some(ConfigRequirementsToml::default())) - }); - - let (first, second) = tokio::join!(loader.get(), loader.get()); - assert_eq!(first, second); - assert_eq!(counter.load(Ordering::SeqCst), 1); - } -} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 86d64e415..dcaefb8db 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -29,7 +29,6 @@ pub enum RequirementSource { domain: String, key: String, }, - CloudRequirements, /// Multiple requirements layers contributed to the final value. Sources are /// stored highest-priority first, matching the order surfaced in errors. Composite { @@ -87,9 +86,6 @@ impl fmt::Display for RequirementSource { RequirementSource::MdmManagedPreferences { domain, key } => { write!(f, "MDM {domain}:{key}") } - RequirementSource::CloudRequirements => { - write!(f, "cloud requirements") - } RequirementSource::Composite { sources } => { write!(f, "requirements layers: ")?; for (index, source) in sources.iter().enumerate() { @@ -1510,22 +1506,6 @@ mod tests { ); } - #[test] - fn composite_requirement_source_display_lists_sources_in_priority_order() { - let source = RequirementSource::composite([ - RequirementSource::MdmManagedPreferences { - domain: "com.openai.codex".to_string(), - key: "requirements_toml_base64".to_string(), - }, - RequirementSource::LegacyManagedConfigTomlFromMdm, - ]); - - assert_eq!( - source.to_string(), - "requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)" - ); - } - fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, @@ -1906,7 +1886,7 @@ mod tests { fn merge_unset_fields_ignores_blank_guardian_override() { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { guardian_policy_config: Some(" \n\t".to_string()), ..Default::default() @@ -2252,8 +2232,11 @@ allowed_approvals_reviewers = ["user"] #[test] fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { - let higher_source = RequirementSource::CloudRequirements; - let lower_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let higher_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let lower_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( @@ -2294,7 +2277,7 @@ allowed_approvals_reviewers = ["user"] let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { apps: Some(apps_requirements(&[])), ..Default::default() @@ -2415,7 +2398,7 @@ allowed_approvals_reviewers = ["user"] "#, )?; - let source_location = RequirementSource::CloudRequirements; + let source_location = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields(source_location.clone(), source); let requirements = ConfigRequirements::try_from(target)?; @@ -2725,7 +2708,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_toml: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -2816,7 +2799,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> { - let high_source = RequirementSource::CloudRequirements; + let high_source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut high_precedence: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -3003,7 +2986,7 @@ statusMessage = "checking" fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, from_str::( r#" [hooks] @@ -3047,7 +3030,7 @@ command = "python3 /system/hooks/pre.py" ); assert_eq!( target.hooks.as_ref().map(|hooks| hooks.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(RequirementSource::LegacyManagedConfigTomlFromMdm) ); Ok(()) } @@ -3111,7 +3094,7 @@ command = "python3 /enterprise/hooks/pre.py" "/tmp/blocked.sock" = "deny" "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); @@ -3184,7 +3167,7 @@ command = "python3 /enterprise/hooks/pre.py" allow_local_binding = false "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index cb3984729..0c79ffacf 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,5 +1,5 @@ +mod cloud_config_bundle; mod cloud_config_layers; -mod cloud_requirements; mod config_layer_source; mod config_requirements; pub mod config_toml; @@ -25,19 +25,25 @@ pub mod schema; mod skills_config; mod state; mod strict_config; +pub mod test_support; mod thread_config; mod tui_keymap; pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use cloud_config_bundle::CloudConfigBundle; +pub use cloud_config_bundle::CloudConfigBundleLayers; +pub use cloud_config_bundle::CloudConfigBundleLoadError; +pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode; +pub use cloud_config_bundle::CloudConfigBundleLoader; +pub use cloud_config_bundle::CloudConfigTomlBundle; +pub use cloud_config_bundle::CloudRequirementsFragment; +pub use cloud_config_bundle::CloudRequirementsTomlBundle; pub use cloud_config_layers::CloudConfigFragment; pub use cloud_config_layers::CloudConfigFragmentSource; pub use cloud_config_layers::CloudConfigLayerError; pub use cloud_config_layers::cloud_config_layers_from_fragments; -pub use cloud_requirements::CloudRequirementsLoadError; -pub use cloud_requirements::CloudRequirementsLoadErrorCode; -pub use cloud_requirements::CloudRequirementsLoader; pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index e004cea87..b711ebbb6 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -1,6 +1,6 @@ # `codex-config` loader -This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: +This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, cloud-managed config, managed config, and MDM-managed preferences) and to produce: - An **effective merged** TOML config. - **Per-key origins** metadata (which layer “wins” for a given key). @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -25,13 +25,19 @@ Exported from `codex_config::loader`: Precedence is **top overrides bottom**: -1. **MDM** managed preferences (macOS only) -2. **System** managed config (e.g. `managed_config.toml`) -3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) -4. **User** config (`config.toml`) +1. `LegacyManagedConfigTomlFromMdm` (MDM-delivered `managed_config.toml`, while it is being phased out) +2. `LegacyManagedConfigTomlFromFile` (`managed_config.toml`, while it is being phased out) +3. `SessionFlags` (CLI overrides, applied as dotted-path TOML writes) +4. `Project` config (`.codex/config.toml`) +5. `User` profile config, when present +6. `User` config (`config.toml`) +7. `EnterpriseManaged` cloud-managed config bundle layers +8. `System` config (`/etc/codex/config.toml` or the Windows system config path) -Thread config entries supplied by `thread_config_loader` are inserted according -to their translated `ConfigLayerSource` precedence. +`ConfigLayerStack` stores layers in the opposite order internally: lowest +precedence first, highest precedence last, so later layers override earlier +layers when folded. Thread config entries supplied by `thread_config_loader` are +inserted according to their translated `ConfigLayerSource` precedence. Layers with a `disabled_reason` are still surfaced for UI, but are ignored when computing the effective config and origins metadata. This is what @@ -42,9 +48,8 @@ computing the effective config and origins metadata. This is what Most callers want the effective config plus metadata: ```rust -use codex_config::NoopThreadConfigLoader; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; @@ -58,7 +63,6 @@ let layers = load_config_layers_state( Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ).await?; diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 199e7f6da..974cb2760 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -1,6 +1,4 @@ -use super::merge_requirements_with_remote_sandbox_config; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; use crate::config_requirements::RequirementSource; use crate::config_toml::ConfigToml; use crate::diagnostics::ConfigDiagnosticSource; @@ -75,35 +73,27 @@ fn load_managed_admin_config( .transpose() } -pub(crate) async fn load_managed_admin_requirements_toml( - target: &mut ConfigRequirementsWithSources, +pub(crate) async fn load_managed_admin_requirements_layer( override_base64: Option<&str>, -) -> io::Result<()> { +) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); if trimmed.is_empty() { - return Ok(()); + return Ok(None); } - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - parse_managed_requirements_base64(trimmed)?, - ); - return Ok(()); + return parse_managed_requirements_base64(trimmed).map(|contents| { + Some(RequirementsLayerEntry::from_toml( + managed_preferences_requirements_source(), + contents, + )) + }); } match task::spawn_blocking(load_managed_admin_requirements).await { - Ok(result) => { - if let Some(requirements) = result? { - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - requirements, - ); - } - Ok(()) - } + Ok(result) => Ok(result?.map(|contents| { + RequirementsLayerEntry::from_toml(managed_preferences_requirements_source(), contents) + })), Err(join_err) => { if join_err.is_cancelled() { tracing::error!("Managed requirements load task was cancelled"); @@ -115,7 +105,7 @@ pub(crate) async fn load_managed_admin_requirements_toml( } } -fn load_managed_admin_requirements() -> io::Result> { +fn load_managed_admin_requirements() -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)? .as_deref() .map(str::trim) @@ -217,13 +207,8 @@ fn validate_managed_config_toml_strictly_if_requested( } } -fn parse_managed_requirements_base64(encoded: &str) -> io::Result { - toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err( - |err| { - tracing::error!("Failed to parse managed requirements TOML: {err}"); - io::Error::new(io::ErrorKind::InvalidData, err) - }, - ) +fn parse_managed_requirements_base64(encoded: &str) -> io::Result { + decode_managed_preferences_base64(encoded) } fn decode_managed_preferences_base64(encoded: &str) -> io::Result { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index a657af0f3..bc0fb482e 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -6,10 +6,10 @@ mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; +use crate::CloudConfigBundleLayers; use crate::ProfileV2Name; -use crate::cloud_requirements::CloudRequirementsLoader; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; +use crate::compose_requirements; use crate::config_requirements::RequirementSource; use crate::config_requirements::SandboxModeRequirement; use crate::config_toml::ConfigToml; @@ -75,14 +75,16 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O typed_first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE).await } -/// To build up the set of admin-enforced constraints, we build up from multiple -/// configuration layers in the following order, but a constraint defined in an -/// earlier layer cannot be overridden by a later layer: +/// To build up the set of admin-enforced constraints, requirements layers are +/// collected in ascending precedence order, matching config layers, and then +/// composed with config-style TOML merging plus field-specific handling for +/// hooks, rules, deny-read permissions, and remote sandbox config: /// -/// - cloud: managed cloud requirements -/// - admin: managed preferences (*) /// - system `/etc/codex/requirements.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows) +/// - cloud: enterprise-managed cloud config bundle requirements +/// - legacy: managed_config.toml reinterpreted as requirements.toml +/// - admin: managed preferences (*) /// /// For backwards compatibility, we also load from /// `managed_config.toml` and map it to `requirements.toml`. @@ -92,6 +94,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) +/// - cloud enterprise-managed cloud config bundle fragments /// - user `${CODEX_HOME}/config.toml` /// - profile `${CODEX_HOME}/.config.toml`, when selected /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) @@ -114,56 +117,77 @@ pub async fn load_config_layers_state( cwd: Option, cli_overrides: &[(String, TomlValue)], options: impl Into, - cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { let ConfigLoadOptions { loader_overrides: overrides, strict_config, + cloud_config_bundle, } = options.into(); let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = overrides.ignore_user_and_project_exec_policy_rules; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + let mut requirements_layers = Vec::new(); + let mut bundle_requirements_layers = Vec::new(); + let mut system_requirements_layer = None; + let managed_preferences_requirements_layer; + let mut cloud_config_layers = Vec::new(); if !ignore_managed_requirements { - if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { - merge_requirements_with_remote_sandbox_config( - &mut config_requirements_toml, - RequirementSource::CloudRequirements, - requirements, - ); + if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? { + let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?; + let bundle_layers = if strict_config { + CloudConfigBundleLayers::from_bundle_strict_config(bundle, &cloud_config_base_dir)? + } else { + CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)? + }; + let CloudConfigBundleLayers { + enterprise_managed_config, + enterprise_managed_requirements, + } = bundle_layers; + bundle_requirements_layers = enterprise_managed_requirements; + cloud_config_layers = enterprise_managed_config; } #[cfg(target_os = "macos")] - macos::load_managed_admin_requirements_toml( - &mut config_requirements_toml, - overrides - .macos_managed_config_requirements_base64 - .as_deref(), - ) - .await?; + { + managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer( + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await?; + } + #[cfg(not(target_os = "macos"))] + { + managed_preferences_requirements_layer = None; + } // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?; - load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; + system_requirements_layer = load_requirements_toml(fs, &requirements_toml_file).await?; + } else { + managed_preferences_requirements_layer = None; } - // Make a best-effort to support the legacy `managed_config.toml` as a - // requirements specification. let loaded_config_layers = layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) .await?; if !ignore_managed_requirements { - load_requirements_from_legacy_scheme( - &mut config_requirements_toml, + requirements_layers.extend(system_requirements_layer); + requirements_layers.extend(bundle_requirements_layers); + // Continue to support the legacy `managed_config.toml` locations as + // requirements layers for backwards compatibility. + requirements_layers.extend(requirements_layers_from_legacy_scheme( loaded_config_layers.clone(), - ) - .await?; + )?); + requirements_layers.extend(managed_preferences_requirements_layer); } + let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default(); + let thread_config_context = ThreadConfigContext { thread_id: None, cwd: cwd.clone(), @@ -210,6 +234,7 @@ pub async fn load_config_layers_state( ) .await?; layers.push(system_layer); + layers.extend(cloud_config_layers); // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to @@ -535,15 +560,12 @@ fn validate_cli_overrides_strictly( Ok(()) } -/// If available, apply requirements from the platform system -/// `requirements.toml` location to `config_requirements_toml` by filling in -/// any unset fields. -#[doc(hidden)] +/// If available, load requirements from the platform's system `requirements.toml` +/// location as a requirements layer. pub async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, - config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, -) -> io::Result<()> { +) -> io::Result> { match fs .read_file_text(requirements_toml_file, /*sandbox*/ None) .await @@ -558,39 +580,31 @@ pub async fn load_requirements_toml( ), ) })?; - let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path()); - let requirements_config: ConfigRequirementsToml = - toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing requirements file {}: {e}", - requirements_toml_file.as_path().display(), - ), - ) - })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, - RequirementSource::SystemRequirementsToml { - file: requirements_toml_file.clone(), - }, - requirements_config, - ); + let base_dir = AbsolutePathBuf::from_absolute_path(requirements_parent)?; + Ok(Some( + RequirementsLayerEntry::from_toml( + RequirementSource::SystemRequirementsToml { + file: requirements_toml_file.clone(), + }, + contents, + ) + .with_base_dir(base_dir), + )) } Err(e) => { if e.kind() != io::ErrorKind::NotFound { - return Err(io::Error::new( + Err(io::Error::new( e.kind(), format!( "Failed to read requirements file {}: {e}", requirements_toml_file.as_path().display(), ), - )); + )) + } else { + Ok(None) } } } - - Ok(()) } #[cfg(unix)] @@ -704,31 +718,33 @@ fn windows_program_data_dir_from_known_folder() -> io::Result { Ok(path) } -async fn load_requirements_from_legacy_scheme( - config_requirements_toml: &mut ConfigRequirementsWithSources, +fn requirements_layers_from_legacy_scheme( loaded_config_layers: LoadedConfigLayers, -) -> io::Result<()> { - // In this implementation, earlier layers cannot be overwritten by later - // layers, so list managed_config_from_mdm first because it has the highest - // precedence. +) -> io::Result> { + // List the file-backed legacy layer first because requirements layers are + // composed lowest-precedence to highest-precedence, and MDM has higher + // precedence than the legacy managed_config.toml file. let LoadedConfigLayers { managed_config, managed_config_from_mdm, } = loaded_config_layers; - for (source, config) in managed_config_from_mdm - .map(|config| { - ( - RequirementSource::LegacyManagedConfigTomlFromMdm, - config.managed_config, - ) - }) - .into_iter() - .chain(managed_config.map(|c| { + let layer_count = + usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some()); + let mut layers = Vec::with_capacity(layer_count); + for (source, config) in managed_config + .map(|c| { ( RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file }, c.managed_config, ) + }) + .into_iter() + .chain(managed_config_from_mdm.map(|config| { + ( + RequirementSource::LegacyManagedConfigTomlFromMdm, + config.managed_config, + ) })) { let legacy_config: LegacyManagedConfigToml = @@ -739,26 +755,56 @@ async fn load_requirements_from_legacy_scheme( ) })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, + layers.push(RequirementsLayerEntry::from_toml_value( source, - ConfigRequirementsToml::from(legacy_config), - ); + legacy_requirements_to_toml_value(legacy_config)?, + )); } - Ok(()) + Ok(layers) } -pub(super) fn merge_requirements_with_remote_sandbox_config( - target: &mut ConfigRequirementsWithSources, - source: RequirementSource, - mut requirements: ConfigRequirementsToml, -) { - if requirements.remote_sandbox_config.is_some() { - let host_name = crate::host_name(); - requirements.apply_remote_sandbox_config(host_name.as_deref()); +fn legacy_requirements_to_toml_value(legacy: LegacyManagedConfigToml) -> io::Result { + let LegacyManagedConfigToml { + approval_policy, + approvals_reviewer, + sandbox_mode, + } = legacy; + let mut table = toml::map::Map::new(); + if let Some(approval_policy) = approval_policy { + table.insert( + "allowed_approval_policies".to_string(), + toml_value_from_serializable(vec![approval_policy])?, + ); } - target.merge_unset_fields(source, requirements); + if let Some(approvals_reviewer) = approvals_reviewer { + let mut allowed_reviewers = vec![approvals_reviewer]; + if approvals_reviewer == ApprovalsReviewer::AutoReview { + allowed_reviewers.push(ApprovalsReviewer::User); + } + table.insert( + "allowed_approvals_reviewers".to_string(), + toml_value_from_serializable(allowed_reviewers)?, + ); + } + if let Some(sandbox_mode) = sandbox_mode { + let required_mode: SandboxModeRequirement = sandbox_mode.into(); + // Allowing read-only is a requirement for Codex to function correctly. + // So in this backfill path, we append read-only if it's not already specified. + let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; + if required_mode != SandboxModeRequirement::ReadOnly { + allowed_modes.push(required_mode); + } + table.insert( + "allowed_sandbox_modes".to_string(), + toml_value_from_serializable(allowed_modes)?, + ); + } + Ok(TomlValue::Table(table)) +} + +fn toml_value_from_serializable(value: T) -> io::Result { + TomlValue::try_from(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } struct ProjectTrustContext { @@ -1342,39 +1388,6 @@ struct LegacyManagedConfigToml { sandbox_mode: Option, } -impl From for ConfigRequirementsToml { - fn from(legacy: LegacyManagedConfigToml) -> Self { - let mut config_requirements_toml = ConfigRequirementsToml::default(); - - let LegacyManagedConfigToml { - approval_policy, - approvals_reviewer, - sandbox_mode, - } = legacy; - if let Some(approval_policy) = approval_policy { - config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); - } - if let Some(approvals_reviewer) = approvals_reviewer { - let mut allowed_reviewers = vec![approvals_reviewer]; - if approvals_reviewer == ApprovalsReviewer::AutoReview { - allowed_reviewers.push(ApprovalsReviewer::User); - } - config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers); - } - if let Some(sandbox_mode) = sandbox_mode { - let required_mode: SandboxModeRequirement = sandbox_mode.into(); - // Allowing read-only is a requirement for Codex to function correctly. - // So in this backfill path, we append read-only if it's not already specified. - let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; - if required_mode != SandboxModeRequirement::ReadOnly { - allowed_modes.push(required_mode); - } - config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes); - } - config_requirements_toml - } -} - // Cannot name this `mod tests` because of tests.rs in this folder. #[cfg(test)] mod unit_tests { @@ -1421,54 +1434,63 @@ foo = "xyzzy" } #[test] - fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() { + fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: None, sandbox_mode: Some(SandboxMode::WorkspaceWrite), }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_sandbox_modes, - Some(vec![ - SandboxModeRequirement::ReadOnly, - SandboxModeRequirement::WorkspaceWrite - ]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_sandbox_modes".to_string(), + TomlValue::Array(vec![ + TomlValue::String("read-only".to_string()), + TomlValue::String("workspace-write".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() { + fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![ + TomlValue::String("guardian_subagent".to_string()), + TomlValue::String("user".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() { + fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::User]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![TomlValue::String("user".to_string())]), + )])) ); + Ok(()) } #[cfg(windows)] diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 9d4511a8a..6492f9a0c 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -128,7 +128,6 @@ model = "gpt-work" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -187,7 +186,6 @@ model = "gpt-main" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -244,7 +242,6 @@ model = "gpt-dev" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index e4d03c3d3..d5f2b050c 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -5,6 +5,7 @@ use super::fingerprint::record_origins; use super::fingerprint::version_for_toml; use super::key_aliases::normalized_with_key_aliases; use super::merge::merge_toml_values; +use crate::CloudConfigBundleLoader; use crate::ProfileV2Name; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -21,6 +22,7 @@ use toml::Value as TomlValue; pub struct ConfigLoadOptions { pub loader_overrides: LoaderOverrides, pub strict_config: bool, + pub cloud_config_bundle: CloudConfigBundleLoader, } impl From for ConfigLoadOptions { @@ -28,6 +30,7 @@ impl From for ConfigLoadOptions { Self { loader_overrides, strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), } } } @@ -471,8 +474,8 @@ impl ConfigLayerStack { /// Returns the merged config-layer view. /// - /// This only merges ordinary config layers and does not apply requirements - /// such as cloud requirements. + /// This only merges ordinary config layers. Requirements are composed and + /// tracked separately. pub fn effective_config(&self) -> TomlValue { let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.get_layers( diff --git a/codex-rs/config/src/strict_config.rs b/codex-rs/config/src/strict_config.rs index fb64458e0..c1523c706 100644 --- a/codex-rs/config/src/strict_config.rs +++ b/codex-rs/config/src/strict_config.rs @@ -37,7 +37,6 @@ pub(crate) fn config_error_from_ignored_toml_value_fields( ) } -#[cfg(any(target_os = "macos", test))] pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name( source_name: &str, contents: &str, diff --git a/codex-rs/config/src/test_support.rs b/codex-rs/config/src/test_support.rs new file mode 100644 index 000000000..44627f389 --- /dev/null +++ b/codex-rs/config/src/test_support.rs @@ -0,0 +1,80 @@ +//! Test-only helpers exposed for cross-crate integration tests. +//! +//! Production code should not depend on this module. + +use crate::CloudConfigBundle; +use crate::CloudConfigBundleLoader; +use crate::CloudConfigFragment; +use crate::CloudRequirementsFragment; + +#[derive(Debug, Clone, Default)] +pub struct CloudConfigBundleFixture { + bundle: CloudConfigBundle, +} + +impl CloudConfigBundleFixture { + pub fn enterprise_requirement(contents: impl Into) -> Self { + Self::default().add_enterprise_requirement(contents) + } + + pub fn enterprise_config(contents: impl Into) -> Self { + Self::default().add_enterprise_config(contents) + } + + pub fn loader_with_enterprise_requirement( + contents: impl Into, + ) -> CloudConfigBundleLoader { + Self::enterprise_requirement(contents).into_loader() + } + + pub fn loader_with_enterprise_config(contents: impl Into) -> CloudConfigBundleLoader { + Self::enterprise_config(contents).into_loader() + } + + pub fn add_enterprise_requirement(mut self, contents: impl Into) -> Self { + let index = self.bundle.requirements_toml.enterprise_managed.len() + 1; + self.bundle + .requirements_toml + .enterprise_managed + .push(CloudRequirementsFragment { + id: format!("req_{index}"), + name: if index == 1 { + "Base requirements".to_string() + } else { + format!("Requirements {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn add_enterprise_config(mut self, contents: impl Into) -> Self { + let index = self.bundle.config_toml.enterprise_managed.len() + 1; + self.bundle + .config_toml + .enterprise_managed + .push(CloudConfigFragment { + id: format!("cfg_{index}"), + name: if index == 1 { + "Base config".to_string() + } else { + format!("Config {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn into_bundle(self) -> CloudConfigBundle { + self.bundle + } + + pub fn into_loader(self) -> CloudConfigBundleLoader { + let bundle = self.into_bundle(); + CloudConfigBundleLoader::new(async move { Ok(Some(bundle)) }) + } +} + +#[cfg(test)] +#[path = "test_support_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/test_support_tests.rs b/codex-rs/config/src/test_support_tests.rs new file mode 100644 index 000000000..379d6c2be --- /dev/null +++ b/codex-rs/config/src/test_support_tests.rs @@ -0,0 +1,25 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn adds_enterprise_requirements_in_order() { + let bundle = CloudConfigBundleFixture::enterprise_requirement("first") + .add_enterprise_requirement("second") + .into_bundle(); + + assert_eq!( + bundle.requirements_toml.enterprise_managed, + vec![ + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "first".to_string(), + }, + CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Requirements 2".to_string(), + contents: "second".to_string(), + }, + ] + ); +} diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 9e37a1431..07f1fd4f2 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -3,7 +3,6 @@ use std::path::Path; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginsConfigInput; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; @@ -106,7 +105,6 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin Some(cwd), &[], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ) .await diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index d0097d753..5edecebec 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -3,27 +3,31 @@ use crate::config::ConfigOverrides; use crate::config::ConstraintError; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigError; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStackOrdering; use codex_config::ConfigLoadError; +use codex_config::ConfigLoadOptions; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConfigRequirementsWithSources; use codex_config::FilesystemDenyReadPattern; use codex_config::LoaderOverrides; use codex_config::RequirementSource; +use codex_config::RequirementsLayerEntry; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::compose_requirements; use codex_config::config_error_from_ignored_toml_fields; use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::loader::load_config_layers_state; use codex_config::loader::load_requirements_toml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; @@ -46,6 +50,22 @@ fn config_error_from_io(err: &std::io::Error) -> &ConfigError { .expect("expected ConfigLoadError") } +fn cloud_config_bundle_requirement_source() -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + } +} + +async fn load_single_requirements_toml( + requirements_file: &AbsolutePathBuf, +) -> anyhow::Result { + let layer = load_requirements_toml(LOCAL_FS.as_ref(), requirements_file) + .await? + .expect("requirements.toml should load"); + Ok(compose_requirements(vec![layer])?.expect("requirements should be present")) +} + async fn make_config_for_test( codex_home: &Path, project_path: &Path, @@ -147,7 +167,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -179,7 +198,6 @@ invalid = ["#, ignore_user_config: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -210,7 +228,6 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: ignore_user_and_project_exec_policy_rules: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -236,7 +253,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -288,7 +304,6 @@ async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requi Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -323,7 +338,6 @@ command = "python3 /tmp/user-hook.py" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -533,7 +547,6 @@ extra = true Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -567,7 +580,6 @@ async fn returns_empty_when_all_layers_missing() { Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -644,7 +656,6 @@ approval_policy = "on-failure" Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -707,7 +718,6 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { Some(cwd), &[("features.plugins".to_string(), TomlValue::Boolean(true))], overrides, - CloudRequirementsLoader::default(), &StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig { features: BTreeMap::from([("plugins".to_string(), false)]), ..Default::default() @@ -787,7 +797,6 @@ flag = false Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -891,7 +900,6 @@ allowed_sandbox_modes = ["read-only"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -953,7 +961,6 @@ allowed_approval_policies = ["never"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -990,14 +997,8 @@ personality = true ) .await?; - let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = + load_single_requirements_toml(&AbsolutePathBuf::try_from(requirements_file)?).await?; assert_eq!( config_requirements_toml @@ -1073,7 +1074,7 @@ personality = true #[cfg(target_os = "macos")] #[tokio::test] -async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::Result<()> { +async fn mdm_requirements_take_precedence_over_cloud_config_bundle() -> anyhow::Result<()> { use base64::Engine; let tmp = tempdir()?; @@ -1091,49 +1092,34 @@ allowed_approval_policies = ["on-request"] tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], - loader_overrides, - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - })) - }), + ConfigLoadOptions { + loader_overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; assert_eq!( state.requirements().approval_policy.value(), - AskForApproval::Never + AskForApproval::OnRequest ); assert_eq!( state .requirements() .approval_policy - .can_set(&AskForApproval::OnRequest), + .can_set(&AskForApproval::Never), Err(ConstraintError::InvalidValue { field_name: "approval_policy", - candidate: "OnRequest".into(), - allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }, }) ); @@ -1141,7 +1127,7 @@ allowed_approval_policies = ["on-request"] } #[tokio::test(flavor = "current_thread")] -async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { +async fn cloud_config_bundle_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; let requirements_file = tmp.path().join("requirements.toml"); tokio::fs::write( @@ -1152,38 +1138,20 @@ allowed_approval_policies = ["on-request"] ) .await?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - RequirementSource::CloudRequirements, - ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }, - ); - load_requirements_toml( + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_config_bundle_requirement_source(), + r#"allowed_approval_policies = ["never"]"#, + ), + ])? + .expect("requirements should be present"); assert_eq!( config_requirements_toml @@ -1197,7 +1165,7 @@ allowed_approval_policies = ["on-request"] .allowed_approval_policies .as_ref() .map(|sourced| sourced.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(cloud_config_bundle_requirement_source()) ); Ok(()) @@ -1217,22 +1185,21 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] ) .await?; - let cloud_source = RequirementSource::CloudRequirements; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - cloud_source.clone(), - toml::from_str( - r#" -allowed_sandbox_modes = ["read-only"] -"#, - )?, - ); - load_requirements_toml( + let cloud_source = cloud_config_bundle_requirement_source(); + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_source.clone(), + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ])? + .expect("requirements should be present"); let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( @@ -1266,13 +1233,7 @@ deny_read = ["./sensitive", "../shared/secret.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1320,13 +1281,7 @@ deny_read = ["./sensitive/**/*.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1360,44 +1315,26 @@ deny_read = ["./sensitive/**/*.txt"] } #[tokio::test] -async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> { +async fn load_config_layers_includes_cloud_config_bundle() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = r#"allowed_approval_policies = ["never"]"#; + let expected: ConfigRequirementsToml = toml::from_str(requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1415,7 +1352,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> field_name: "approval_policy", candidate: "OnRequest".into(), allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + requirement_source: cloud_config_bundle_requirement_source(), }) ); @@ -1680,6 +1617,88 @@ extends = ":workspace" Ok(()) } +#[tokio::test] +async fn load_config_layers_inserts_cloud_config_between_system_and_user() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"model = "user" +"#, + ) + .await?; + + let system_config_path = tmp.path().join("system_config.toml"); + tokio::fs::write( + &system_config_path, + r#"model = "system" +model_provider = "system-provider" +review_model = "system-review" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_config_path = Some(system_config_path.clone()); + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + r#"model = "cloud" +model_provider = "cloud-provider" +"#, + ), + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let merged = layers.effective_config(); + let table = merged.as_table().expect("merged config should be a table"); + assert_eq!(table.get("model"), Some(&TomlValue::String("user".into()))); + assert_eq!( + table.get("model_provider"), + Some(&TomlValue::String("cloud-provider".into())) + ); + assert_eq!( + table.get("review_model"), + Some(&TomlValue::String("system-review".into())) + ); + assert_eq!( + layers + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::System { + file: AbsolutePathBuf::from_absolute_path(&system_config_path)?, + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + }, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(codex_home.join(CONFIG_TOML_FILE))?, + profile: None, + }, + ] + ); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1706,18 +1725,15 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result< overrides.system_requirements_path = Some(system_requirements_path); overrides.ignore_managed_requirements = true; - let cloud_requirements = CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - ..Default::default() - })) - }); + let cloud_config_bundle = CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ); let mut config = ConfigBuilder::default() .codex_home(codex_home) .fallback_cwd(Some(cwd.to_path_buf())) .loader_overrides(overrides) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await?; @@ -1747,36 +1763,36 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result tokio::fs::create_dir_all(&managed_dir).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - hooks: Some(codex_config::ManagedHooksRequirementsToml { - managed_dir: Some(managed_dir.clone()), - windows_managed_dir: None, - hooks: codex_config::HookEventsToml { - pre_tool_use: vec![codex_config::MatcherGroup { - matcher: Some("^Bash$".to_string()), - hooks: vec![codex_config::HookHandlerConfig::Command { - command: format!("python3 {}/pre.py", managed_dir.display()), - command_windows: None, - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), - }], - }], - ..Default::default() - }, - }), - ..ConfigRequirementsToml::default() - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = format!( + r#" +[hooks] +managed_dir = '{}' + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = 'python3 {}/pre.py' +timeout = 10 +statusMessage = "checking" +"#, + managed_dir.display(), + managed_dir.display() + ); + let expected: ConfigRequirementsToml = toml::from_str(&requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1788,12 +1804,94 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result .managed_hooks .as_ref() .map(|hooks| hooks.source.clone()), - Some(Some(RequirementSource::CloudRequirements)) + Some(Some(cloud_config_bundle_requirement_source())) ); Ok(()) } +#[tokio::test] +async fn load_config_layers_resolves_relative_bundle_requirements_paths_against_codex_home() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let requirements = r#" +[permissions.filesystem] +deny_read = ["secrets/**"] +"#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle, + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let permissions = layers + .requirements_toml() + .permissions + .clone() + .expect("permissions requirements should load"); + let filesystem = permissions + .filesystem + .expect("filesystem requirements should load"); + + assert_eq!( + filesystem.deny_read, + Some(vec![ + FilesystemDenyReadPattern::from_input(&format!("{}/secrets/**", codex_home.display())) + .expect("bundle requirements path should resolve against codex_home") + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cloud_config_key() { + let tmp = tempdir().expect("tempdir"); + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home) + .await + .expect("create codex home"); + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("cwd"); + + let err = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + strict_config: true, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + "unknown_key = true", + ), + }, + &codex_config::NoopThreadConfigLoader, + ) + .await + .expect_err("strict config should reject unknown cloud config keys"); + + assert!( + err.to_string() + .contains("unknown configuration field `unknown_key`"), + "{err:?}" + ); +} + #[tokio::test] async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1801,23 +1899,24 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements: ConfigRequirementsToml = toml::from_str( - r#" + let requirements = r#" allowed_sandbox_modes = ["read-only"] [[remote_sandbox_config]] hostname_patterns = ["*"] allowed_sandbox_modes = ["read-only", "workspace-write"] - "#, - )?; - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + "#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1841,7 +1940,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: } #[tokio::test] -async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyhow::Result<()> { +async fn load_config_layers_fails_when_cloud_config_bundle_loader_fails() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1852,21 +1951,23 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - CloudRequirementsLoader::new(async { - Err(CloudRequirementsLoadError::new( - codex_config::CloudRequirementsLoadErrorCode::RequestFailed, - /*status_code*/ None, - "cloud requirements failed", - )) - }), + ConfigLoadOptions { + cloud_config_bundle: CloudConfigBundleLoader::new(async { + Err(CloudConfigBundleLoadError::new( + codex_config::CloudConfigBundleLoadErrorCode::RequestFailed, + /*status_code*/ None, + "cloud config bundle failed", + )) + }), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await - .expect_err("cloud requirements failure should fail closed"); + .expect_err("cloud config bundle failure should fail closed"); assert_eq!(err.kind(), std::io::ErrorKind::Other); - assert!(err.to_string().contains("cloud requirements failed")); + assert!(err.to_string().contains("cloud config bundle failed")); Ok(()) } @@ -1909,7 +2010,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1992,7 +2092,6 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2074,7 +2173,6 @@ async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_con Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2145,7 +2243,6 @@ async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2333,7 +2430,6 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2374,7 +2470,6 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2440,7 +2535,6 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2516,7 +2610,6 @@ profile = "ignored" Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2563,7 +2656,6 @@ profile = "ignored" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2652,7 +2744,6 @@ wire_api = "responses" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2754,7 +2845,6 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st Some(AbsolutePathBuf::from_absolute_path(&project_root)?), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2920,7 +3010,6 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2989,7 +3078,6 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3050,7 +3138,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3096,7 +3183,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 65d20d94f..795057963 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -104,6 +104,7 @@ use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; use rmcp::model::UrlElicitationCapability; +use codex_config::test_support::CloudConfigBundleFixture; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -1389,15 +1390,14 @@ async fn experimental_network_requirements_enable_proxy_without_feature() -> std let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - network: Some(codex_config::NetworkRequirementsToml { - enabled: Some(true), - ..Default::default() - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +"#, + ), + ) .build() .await?; @@ -1867,7 +1867,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -3932,7 +3932,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { http_mcp("https://example.com/mcp"), ), ]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "sample@test".to_string(), @@ -3982,7 +3982,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { #[test] fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "other@test".to_string(), @@ -4385,7 +4385,7 @@ enabled = true } #[tokio::test] -async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { +async fn to_mcp_config_applies_plugin_mcp_cloud_config_bundle() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() @@ -4422,27 +4422,16 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - McpServerRequirement { - identity: McpServerIdentity::Url { - url: "https://sample.example/mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[plugins."sample@test".mcp_servers.sample.identity] +url = "https://sample.example/mcp" +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4463,7 +4452,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -4504,15 +4496,15 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - mcp_servers: Some(BTreeMap::new()), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[mcp_servers] +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4526,7 +4518,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -5144,7 +5139,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { Some(cwd), &Vec::new(), overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -5278,7 +5272,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -9000,12 +8993,11 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9024,35 +9016,14 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_approval_policies: None, - allowed_approvals_reviewers: None, - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9072,21 +9043,16 @@ sandbox = "unelevated" "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - windows: Some(codex_config::WindowsRequirementsToml { - allowed_sandbox_implementations: Some(vec![ - codex_config::types::WindowsSandboxModeToml::Elevated, - ]), - }), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"[windows] +allowed_sandbox_implementations = ["elevated"] +"#, + ), + ) .build() .await?; @@ -9117,12 +9083,11 @@ sandbox_mode = "danger-full-access" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained yolo should require sandbox approval"); @@ -9152,12 +9117,11 @@ default_permissions = "dev" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained full-access profile should require sandbox approval"); @@ -9174,11 +9138,6 @@ default_permissions = "dev" async fn permission_profile_override_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9186,9 +9145,11 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( permission_profile: Some(PermissionProfile::Disabled), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9204,11 +9165,6 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( #[tokio::test] async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9216,9 +9172,11 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9328,14 +9286,11 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_web_search_modes: Some(vec![ - codex_config::WebSearchModeRequirement::Cached, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_web_search_modes = ["cached"]"#, + ), + ) .build() .await?; @@ -9369,12 +9324,11 @@ trust_level = "untrusted" let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(workspace.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; @@ -9398,12 +9352,11 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9419,17 +9372,15 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9453,14 +9404,14 @@ async fn feature_requirements_auto_review_disables_guardian_approval() -> std::i let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("auto_review".to_string(), false)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +auto_review = false +"#, + ), + ) .build() .await?; @@ -9475,17 +9426,15 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("in_app_browser".to_string(), false), - ("browser_use".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +in_app_browser = false +browser_use = false +"#, + ), + ) .build() .await?; @@ -9581,17 +9530,15 @@ shell_tool = true let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9701,12 +9648,11 @@ async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_requi let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9727,12 +9673,11 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9748,6 +9693,37 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> Ok(()) } +#[tokio::test] +async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let selected_config = codex_home.path().join("default.config.toml"); + std::fs::write( + &selected_config, + r#"approvals_reviewer = "user" +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + user_config_path: Some(selected_config.abs()), + user_config_profile: Some("default".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) + .build() + .await?; + + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); + Ok(()) +} + #[tokio::test] async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() -> std::io::Result<()> { @@ -9761,15 +9737,11 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ - ApprovalsReviewer::User, - ApprovalsReviewer::AutoReview, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["user", "guardian_subagent"]"#, + ), + ) .build() .await?; @@ -10255,17 +10227,15 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io:: let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -10291,14 +10261,14 @@ async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<( let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("collab".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +collab = true +"#, + ), + ) .build() .await?; @@ -10321,14 +10291,14 @@ async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Resu let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("made_up_feature".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +made_up_feature = true +"#, + ), + ) .build() .await?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 051064f27..9506819d0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -7,7 +7,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; @@ -1112,7 +1112,7 @@ pub struct ConfigBuilder { harness_overrides: Option, loader_overrides: Option, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, thread_config_loader: Option>, fallback_cwd: Option, } @@ -1143,8 +1143,8 @@ impl ConfigBuilder { self } - pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = cloud_requirements; + pub fn cloud_config_bundle(mut self, cloud_config_bundle: CloudConfigBundleLoader) -> Self { + self.cloud_config_bundle = cloud_config_bundle; self } @@ -1173,7 +1173,7 @@ impl ConfigBuilder { harness_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, thread_config_loader, fallback_cwd, } = self; @@ -1198,8 +1198,8 @@ impl ConfigBuilder { ConfigLoadOptions { loader_overrides, strict_config, + cloud_config_bundle, }, - cloud_requirements, thread_config_loader .as_deref() .unwrap_or(&codex_config::NoopThreadConfigLoader), @@ -1623,7 +1623,6 @@ pub async fn load_config_as_toml_with_cli_and_load_options( cwd.cloned(), &cli_overrides, options, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1834,7 +1833,6 @@ pub async fn load_global_mcp_servers( cwd, &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index c01fd5467..0427ce5a4 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -5,10 +5,10 @@ use codex_config::AppRequirementToml; use codex_config::AppToolRequirementToml; use codex_config::AppToolsRequirementsToml; use codex_config::AppsRequirementsToml; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; @@ -455,15 +455,13 @@ approvals_reviewer = "user" "#, ) .expect("write config"); - let requirements = ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["auto_review"]"#, + ), + ) .build() .await .expect("config should build"); @@ -541,7 +539,7 @@ fn requirements_enabled_does_not_override_disabled_connector() { } #[tokio::test] -async fn cloud_requirements_disable_connector_overrides_user_apps_config() { +async fn cloud_config_bundle_disable_connector_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -552,25 +550,17 @@ enabled = true ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -592,29 +582,21 @@ enabled = true } #[tokio::test] -async fn cloud_requirements_disable_connector_applies_without_user_apps_table() { +async fn cloud_config_bundle_disable_connector_applies_without_user_apps_table() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), "").expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -870,7 +852,7 @@ fn managed_app_tool_approval_uses_raw_tool_name() { } #[tokio::test] -async fn cloud_requirements_tool_approval_overrides_user_apps_config() { +async fn cloud_config_bundle_tool_approval_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -881,21 +863,17 @@ approval_mode = "prompt" ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(app_tool_requirements( - "connector_123123", - "calendar/list_events", - AppToolApproval::Approve, - )), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123.tools."calendar/list_events"] +approval_mode = "approve" +"#, + ), + ) .build() .await .expect("config should build"); diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index a2d5fdaa1..b91eaa26c 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -10,7 +10,6 @@ use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; @@ -60,7 +59,6 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec an enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -7101,7 +7101,7 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let layers = config .config_layer_stack diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 3d7fd2122..be07b803f 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -8,10 +8,9 @@ use ctor::ctor; use std::sync::OnceLock; use tempfile::TempDir; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; -use codex_config::NetworkRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_core::CodexThread; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -169,40 +168,37 @@ pub fn fetch_dotslash_file( /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. pub async fn load_default_config_for_test(codex_home: &TempDir) -> Config { - load_default_config_for_test_with_cloud_requirements( + load_default_config_for_test_with_cloud_config_bundle( codex_home, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) .await } -/// Returns a default `Config` with test-provided cloud requirements applied +/// Returns a default `Config` with test-provided cloud bundle requirements applied. /// during config construction. -pub async fn load_default_config_for_test_with_cloud_requirements( +pub async fn load_default_config_for_test_with_cloud_config_bundle( codex_home: &TempDir, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Config { ConfigBuilder::default() .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(default_test_overrides()) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await .expect("defaults for test should always succeed") } -pub fn managed_network_requirements_loader() -> CloudRequirementsLoader { - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - network: Some(NetworkRequirementsToml { - enabled: Some(true), - allow_local_binding: Some(true), - ..Default::default() - }), - ..Default::default() - })) - }) +pub fn managed_network_requirements_loader() -> CloudConfigBundleLoader { + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +allow_local_binding = true +"#, + ) } #[cfg(target_os = "linux")] diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 73e9e0ece..4fe0609a7 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -12,7 +12,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::config::Config; @@ -49,7 +49,7 @@ use crate::PathBufExt; use crate::TempDirExt; use crate::get_remote_test_env; use crate::load_default_config_for_test; -use crate::load_default_config_for_test_with_cloud_requirements; +use crate::load_default_config_for_test_with_cloud_config_bundle; use crate::responses::WebSocketTestServer; use crate::responses::output_value_to_text; use crate::responses::start_mock_server; @@ -213,7 +213,7 @@ pub struct TestCodexBuilder { pre_build_hooks: Vec>, workspace_setups: Vec>, home: Option>, - cloud_requirements: Option, + cloud_config_bundle: Option, user_shell_override: Option, exec_server_url: Option, } @@ -262,8 +262,11 @@ impl TestCodexBuilder { self } - pub fn with_cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = Some(cloud_requirements); + pub fn with_cloud_config_bundle( + mut self, + cloud_config_bundle: CloudConfigBundleLoader, + ) -> Self { + self.cloud_config_bundle = Some(cloud_config_bundle); self } @@ -545,8 +548,8 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - let mut config = if let Some(cloud_requirements) = self.cloud_requirements.take() { - load_default_config_for_test_with_cloud_requirements(home, cloud_requirements).await + let mut config = if let Some(cloud_config_bundle) = self.cloud_config_bundle.take() { + load_default_config_for_test_with_cloud_config_bundle(home, cloud_config_bundle).await } else { load_default_config_for_test(home).await }; @@ -1031,7 +1034,7 @@ pub fn test_codex() -> TestCodexBuilder { pre_build_hooks: vec![], workspace_setups: vec![], home: None, - cloud_requirements: None, + cloud_config_bundle: None, user_shell_override: None, exec_server_url: None, } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a91196a0b..aceac7320 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2999,7 +2999,7 @@ allow_local_binding = true let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config @@ -3279,7 +3279,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); }); @@ -3484,7 +3484,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); let cwd = config.cwd.clone(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index a535eb4b8..5813508e7 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2229,7 +2229,7 @@ allow_local_binding = true panic!("failed to write permission request hook test fixture: {error}"); } }) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { trust_discovered_hooks(config); config.permissions.approval_policy = Constrained::allow_any(approval_policy); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index ba754b025..d86a0f620 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -916,7 +916,7 @@ allow_local_binding = true let permission_profile = permission_profile_for_config.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.use_experimental_unified_exec_tool = true; config diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index e857f358c..406197d72 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -51,7 +51,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; @@ -330,53 +331,54 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ..Default::default() }; - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), - ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = cloud_requirements_loader_for_storage( + 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(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = loader_overrides.clone(); - let run_cloud_requirements = cloud_requirements.clone(); + let run_cloud_config_bundle = cloud_config_bundle.clone(); let model_provider = if oss { - let resolved = resolve_oss_provider(oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + Some(&config_cwd), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -437,7 +439,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .harness_overrides(overrides) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() }; let config = build_exec_config( @@ -536,7 +538,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, strict_config, - cloud_requirements: run_cloud_requirements, + cloud_config_bundle: run_cloud_config_bundle, feedback: CodexFeedback::new(), log_db: None, state_db: state_db.clone(), @@ -606,6 +608,46 @@ where } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let ExecRunArgs { in_process_start_args, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index d9bebb1b3..8b2f21498 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -578,6 +578,7 @@ async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() let codex_home = tempdir().expect("create temp codex home"); let cwd = tempdir().expect("create temp cwd"); let config = ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { sandbox_mode: Some(SandboxMode::DangerFullAccess), diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index fd13753ed..92d17fbab 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -274,9 +274,6 @@ fn fallback_managed_hooks_source_path( Some(RequirementSource::MdmManagedPreferences { domain, key }) => { synthetic_layer_path(&format!("/requirements.toml")) } - Some(RequirementSource::CloudRequirements) => { - synthetic_layer_path("/requirements.toml") - } Some(RequirementSource::Composite { .. }) => { synthetic_layer_path("/requirements.toml") } @@ -635,7 +632,6 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { HookSource::LegacyManagedConfigMdm } - Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements, Some(RequirementSource::Composite { sources }) => { // Requirements hook composition preserves contributing sources in // priority order, but discovery only carries one source for the diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 9b642503a..4b7697b34 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -120,12 +120,12 @@ fn requirements_with_managed_hooks_only( ConfigRequirements { allow_managed_hooks_only: Some(Sourced::new( allow_managed_hooks_only, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), managed_hooks: managed_hooks.clone().map(|hooks| { ConstrainedWithSource::new( Constrained::allow_any(hooks), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ) }), ..ConfigRequirements::default() @@ -183,7 +183,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -208,7 +208,10 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, @@ -286,7 +289,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -447,7 +450,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -471,7 +474,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), @@ -671,7 +677,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -1237,7 +1243,12 @@ print(json.dumps({ assert_eq!(outcome.hook_events.len(), 1); assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin); - assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed); + assert_eq!( + outcome.hook_events[0].run.status, + HookRunStatus::Completed, + "hook entries: {:#?}", + outcome.hook_events[0].run.entries + ); assert_eq!(outcome.hook_events[0].run.entries.len(), 1); assert_eq!( outcome.hook_events[0].run.entries[0].kind, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c9d91eed5..562df9598 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -628,7 +628,7 @@ mod tests { let requirements = ConfigRequirements { approval_policy: ConstrainedWithSource::new( Constrained::allow_any(AskForApproval::OnRequest.to_core()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), approvals_reviewer: ConstrainedWithSource::new( Constrained::allow_any(ApprovalsReviewer::AutoReview), @@ -653,25 +653,25 @@ mod tests { )), enforce_residency: ConstrainedWithSource::new( Constrained::allow_any(Some(ResidencyRequirement::Us)), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), allow_managed_hooks_only: Some(Sourced::new( /*value*/ true, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), allow_appshots: Some(Sourced::new( /*value*/ false, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), network: Some(Sourced::new( NetworkConstraints { @@ -684,7 +684,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), filesystem: Some(Sourced::new( FilesystemConstraints { @@ -694,7 +694,7 @@ mod tests { file: requirements_file.clone(), }, )), - guardian_policy_config_source: Some(RequirementSource::CloudRequirements), + guardian_policy_config_source: Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ..ConfigRequirements::default() }; @@ -749,9 +749,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_approval_policies: on-request (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_approval_policies: on-request (source: {requirements_source})" + ))); assert!(rendered.contains( "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" )); @@ -764,22 +765,28 @@ mod tests { .as_str(), ) ); - assert!( - rendered.contains( - "allowed_web_search_modes: cached, disabled (source: cloud requirements)" - ) - ); - assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); - assert!(rendered.contains("allow_appshots: false (source: cloud requirements)")); - assert!( - rendered.contains("guardian_policy_config: configured (source: cloud requirements)") - ); - assert!(rendered.contains("features: guardian_approval=true (source: cloud requirements)")); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: cached, disabled (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_managed_hooks_only: true (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_appshots: false (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "guardian_policy_config: configured (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "features: guardian_approval=true (source: {requirements_source})" + ))); assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); - assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); - assert!(rendered.contains( - "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" - )); + assert!(rendered.contains(&format!( + "enforce_residency: us (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "experimental_network: enabled=true, domains={{example.com=allow}} (source: {requirements_source})" + ))); assert!( rendered.contains( format!( @@ -834,7 +841,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), ..ConfigRequirements::default() }; @@ -844,9 +851,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!(rendered.contains( - "experimental_network: unix_sockets={/tmp/blocked.sock=deny, /tmp/codex.sock=allow} (source: cloud requirements)" - )); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})" + ))); } #[test] @@ -956,7 +964,7 @@ approval_policy = "never" let requirements = ConfigRequirements { web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Disabled), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), ..ConfigRequirements::default() }; @@ -988,9 +996,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_web_search_modes: disabled (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: disabled (source: {requirements_source})" + ))); } #[test] @@ -1018,7 +1027,7 @@ approval_policy = "never" ..Default::default() }, }), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }; @@ -1033,9 +1042,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains("hooks:")); assert!(rendered.contains("handlers=1")); - assert!(rendered.contains("(source: cloud requirements)")); + assert!(rendered.contains(&format!("(source: {requirements_source})"))); } #[test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e9cc88dca..b53ec0f83 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -38,8 +38,8 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; -use codex_cloud_config::cloud_requirements_loader_for_storage; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; @@ -298,7 +298,7 @@ async fn start_embedded_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -310,7 +310,7 @@ async fn start_embedded_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -510,7 +510,7 @@ async fn start_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -523,7 +523,7 @@ async fn start_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -550,7 +550,7 @@ pub(crate) async fn start_app_server_for_picker( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -584,7 +584,7 @@ async fn start_embedded_app_server_with( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -611,7 +611,7 @@ where cli_overrides: cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -977,51 +977,58 @@ pub async fn run_main( loader_overrides.user_config_profile = Some(profile_v2.clone()); } - #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), - codex_config::ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + 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(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; + let cwd_override = if app_server_target.uses_remote_workspace() { + None + } else { + cwd.clone() + }; + let mut manually_selected_oss_provider = None; let model_provider_override = if cli.oss { - let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if cli.oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + config_cwd.as_ref(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -1062,11 +1069,7 @@ pub async fn run_main( model, approval_policy, sandbox_mode, - cwd: if app_server_target.uses_remote_workspace() { - None - } else { - cwd - }, + cwd: cwd_override, model_provider: model_provider_override.clone(), codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), @@ -1081,7 +1084,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1137,7 +1140,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1156,6 +1159,11 @@ pub async fn run_main( tracing::warn!(error = %err, "failed to deserialize config for personality migration"); } } + let config_toml_log_dir_configured = config + .config_layer_stack + .effective_config() + .as_table() + .is_some_and(|table| table.contains_key("log_dir")); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -1199,7 +1207,7 @@ pub async fn run_main( } } - let (tui_file_layer, _tui_file_log_guard) = if config_toml.log_dir.is_some() { + let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured { let log_dir = config.log_dir.clone(); std::fs::create_dir_all(&log_dir)?; let mut log_file_opts = OpenOptions::new(); @@ -1281,7 +1289,7 @@ pub async fn run_main( manually_selected_oss_provider, overrides, cli_kv_overrides, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -1303,7 +1311,7 @@ async fn run_ratatui_app( manually_selected_oss_provider: Option, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - mut cloud_requirements: CloudRequirementsLoader, + mut cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -1365,7 +1373,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1448,11 +1456,11 @@ async fn run_ratatui_app( { trust_decision_was_made = onboarding_result.directory_trust_persisted; } - // If this onboarding run included the login step, always refresh cloud requirements and - // rebuild config. This avoids missing newly available cloud requirements due to login + // If this onboarding run included the login step, always refresh the cloud config bundle + // and rebuild config. This avoids missing newly available cloud-managed policy due to login // status detection edge cases. if show_login_screen && !uses_remote_workspace { - cloud_requirements = cloud_requirements_loader_for_storage( + cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, initial_config.cli_auth_credentials_store_mode, @@ -1470,7 +1478,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1674,7 +1682,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, fallback_cwd, ) @@ -1685,7 +1693,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1745,7 +1753,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1902,14 +1910,14 @@ async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, ) -> Config { load_config_or_exit_with_fallback_cwd( cli_kv_overrides, overrides, loader_overrides, - cloud_requirements, + cloud_config_bundle, strict_config, /*fallback_cwd*/ None, ) @@ -1920,7 +1928,7 @@ async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, fallback_cwd: Option, ) -> Config { @@ -1930,7 +1938,7 @@ async fn load_config_or_exit_with_fallback_cwd( .harness_overrides(overrides) .loader_overrides(loader_overrides) .strict_config(strict_config) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .fallback_cwd(fallback_cwd) .build() .await @@ -1943,6 +1951,46 @@ async fn load_config_or_exit_with_fallback_cwd( } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + codex_config::ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + /// Determine if the user has decided whether to trust the current directory. fn should_show_trust_screen(config: &Config) -> bool { config.active_project.trust_level.is_none() @@ -2017,7 +2065,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -2771,7 +2819,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, /*state_db*/ None, diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index fa3561a7f..0d8a1fa77 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1012,7 +1012,7 @@ mod tests { use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_arg0::Arg0DispatchPaths; - use codex_cloud_config::cloud_requirements_loader_for_storage; + use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; @@ -1033,7 +1033,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: Default::default(), strict_config: false, - cloud_requirements: cloud_requirements_loader_for_storage( + cloud_config_bundle: cloud_config_bundle_loader_for_storage( codex_home_path.clone(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 40d33eb2f..4e5c420f6 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -16,7 +16,8 @@ use codex_app_server_protocol::Thread as AppServerThread; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_exec_server::EnvironmentManager; @@ -251,6 +252,7 @@ async fn start_app_server_for_archive_command( ConfigLoadOptions { loader_overrides: loader_overrides.clone(), strict_config, + cloud_config_bundle: CloudConfigBundleLoader::default(), }, ) .await @@ -259,7 +261,7 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + 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(), @@ -298,7 +300,7 @@ async fn start_app_server_for_archive_command( }) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() .await .wrap_err("failed to load configuration")?; @@ -312,7 +314,7 @@ async fn start_app_server_for_archive_command( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db,