From e85d019daaa4c42fc9404678f5dec52e424c9f6a Mon Sep 17 00:00:00 2001 From: gt-oai Date: Fri, 30 Jan 2026 12:03:29 +0000 Subject: [PATCH] Fetch Requirements from cloud (#10167) Load requirements from Codex Backend. It only does this for enterprise customers signed in with ChatGPT. Todo in follow-up PRs: * Add to app-server and exec too * Switch from fail-open to fail-closed on failure --- codex-rs/Cargo.lock | 20 ++ codex-rs/Cargo.toml | 2 + codex-rs/cloud-requirements/BUILD.bazel | 6 + codex-rs/cloud-requirements/Cargo.toml | 26 ++ codex-rs/cloud-requirements/src/lib.rs | 338 ++++++++++++++++++ codex-rs/core/src/config/mod.rs | 35 +- codex-rs/core/src/config/service.rs | 1 + codex-rs/core/src/config_loader/README.md | 3 +- .../src/config_loader/cloud_requirements.rs | 56 +++ .../src/config_loader/config_requirements.rs | 31 ++ codex-rs/core/src/config_loader/mod.rs | 11 + codex-rs/core/src/config_loader/tests.rs | 103 ++++++ codex-rs/core/src/skills/manager.rs | 1 + codex-rs/exec-server/src/posix.rs | 1 + codex-rs/network-proxy/src/state.rs | 7 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 48 ++- 17 files changed, 673 insertions(+), 17 deletions(-) create mode 100644 codex-rs/cloud-requirements/BUILD.bazel create mode 100644 codex-rs/cloud-requirements/Cargo.toml create mode 100644 codex-rs/cloud-requirements/src/lib.rs create mode 100644 codex-rs/core/src/config_loader/cloud_requirements.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 35d312910..44f35f78a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1292,6 +1292,25 @@ dependencies = [ "zstd", ] +[[package]] +name = "codex-cloud-requirements" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "codex-app-server-protocol", + "codex-backend-client", + "codex-core", + "codex-otel", + "codex-protocol", + "pretty_assertions", + "serde_json", + "tempfile", + "tokio", + "toml 0.9.5", + "tracing", +] + [[package]] name = "codex-cloud-tasks" version = "0.0.0" @@ -1895,6 +1914,7 @@ dependencies = [ "codex-backend-client", "codex-chatgpt", "codex-cli", + "codex-cloud-requirements", "codex-common", "codex-core", "codex-feedback", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 519621399..327e6beeb 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -11,6 +11,7 @@ members = [ "arg0", "feedback", "codex-backend-openapi-models", + "cloud-requirements", "cloud-tasks", "cloud-tasks-client", "cli", @@ -71,6 +72,7 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } +codex-cloud-requirements = { path = "cloud-requirements" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli"} codex-client = { path = "codex-client" } diff --git a/codex-rs/cloud-requirements/BUILD.bazel b/codex-rs/cloud-requirements/BUILD.bazel new file mode 100644 index 000000000..88243aff9 --- /dev/null +++ b/codex-rs/cloud-requirements/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "cloud-requirements", + crate_name = "codex_cloud_requirements", +) diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml new file mode 100644 index 000000000..2bfde660b --- /dev/null +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "codex-cloud-requirements" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-backend-client = { workspace = true } +codex-core = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +tokio = { workspace = true, features = ["sync", "time"] } +toml = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +base64 = { workspace = true } +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs new file mode 100644 index 000000000..ccfa60031 --- /dev/null +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -0,0 +1,338 @@ +//! Cloud-hosted config requirements 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 Enterprise ChatGPT customers. +//! +//! Today, fetching is best-effort: on error or timeout, Codex continues without cloud requirements. +//! We expect to tighten this so that Enterprise ChatGPT customers must successfully fetch these +//! requirements before Codex will run. + +use async_trait::async_trait; +use codex_app_server_protocol::AuthMode; +use codex_backend_client::Client as BackendClient; +use codex_core::AuthManager; +use codex_core::auth::CodexAuth; +use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::ConfigRequirementsToml; +use codex_protocol::account::PlanType; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::time::timeout; + +/// This blocks codecs startup, so must be short. +const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(5); + +#[async_trait] +trait RequirementsFetcher: Send + Sync { + /// Returns requirements as a TOML string. + /// + /// TODO(gt): For now, returns an Option. But when we want to make this fail-closed, return a + /// Result. + async fn fetch_requirements(&self, auth: &CodexAuth) -> Option; +} + +struct BackendRequirementsFetcher { + base_url: String, +} + +impl BackendRequirementsFetcher { + fn new(base_url: String) -> Self { + Self { base_url } + } +} + +#[async_trait] +impl RequirementsFetcher for BackendRequirementsFetcher { + async fn fetch_requirements(&self, auth: &CodexAuth) -> Option { + 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" + ); + }) + .ok()?; + + let response = client + .get_config_requirements_file() + .await + .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) + .ok()?; + + let Some(contents) = response.contents else { + tracing::warn!("Cloud requirements response missing contents"); + return None; + }; + + Some(contents) + } +} + +struct CloudRequirementsService { + auth_manager: Arc, + fetcher: Arc, + timeout: Duration, +} + +impl CloudRequirementsService { + fn new( + auth_manager: Arc, + fetcher: Arc, + timeout: Duration, + ) -> Self { + Self { + auth_manager, + fetcher, + timeout, + } + } + + async fn fetch_with_timeout(&self) -> Option { + let _timer = + codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); + let started_at = Instant::now(); + let result = timeout(self.timeout, self.fetch()) + .await + .inspect_err(|_| { + tracing::warn!("Timed out waiting for cloud requirements; continuing without them"); + }) + .ok()?; + + match result.as_ref() { + Some(requirements) => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + requirements = ?requirements, + "Cloud requirements load completed" + ); + } + None => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + "Cloud requirements load completed (none)" + ); + } + } + + result + } + + async fn fetch(&self) -> Option { + let auth = self.auth_manager.auth().await?; + if !(auth.mode == AuthMode::ChatGPT + && auth.account_plan_type() == Some(PlanType::Enterprise)) + { + return None; + } + + let contents = self.fetcher.fetch_requirements(&auth).await?; + parse_cloud_requirements(&contents) + .inspect_err(|err| tracing::warn!(error = %err, "Failed to parse cloud requirements")) + .ok() + .flatten() + } +} + +pub fn cloud_requirements_loader( + auth_manager: Arc, + chatgpt_base_url: String, +) -> CloudRequirementsLoader { + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let task = tokio::spawn(async move { service.fetch_with_timeout().await }); + CloudRequirementsLoader::new(async move { + task.await + .inspect_err(|err| tracing::warn!(error = %err, "Cloud requirements task failed")) + .ok() + .flatten() + }) +} + +fn parse_cloud_requirements( + contents: &str, +) -> Result, toml::de::Error> { + if contents.trim().is_empty() { + return Ok(None); + } + + let requirements: ConfigRequirementsToml = toml::from_str(contents)?; + if requirements.is_empty() { + Ok(None) + } else { + Ok(Some(requirements)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_protocol::protocol::AskForApproval; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::future::pending; + use std::path::Path; + use tempfile::tempdir; + + 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(()) + } + + fn auth_manager_with_api_key() -> Arc { + let tmp = tempdir().expect("tempdir"); + let auth_json = json!({ + "OPENAI_API_KEY": "sk-test-key", + "tokens": null, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new(AuthManager::new( + tmp.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )) + } + + fn auth_manager_with_plan(plan_type: &str) -> Arc { + let tmp = tempdir().expect("tempdir"); + let header = json!({ "alg": "none", "typ": "JWT" }); + let auth_payload = json!({ + "chatgpt_plan_type": plan_type, + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + }); + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": auth_payload, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let auth_json = json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + }, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new(AuthManager::new( + tmp.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )) + } + + fn parse_for_fetch(contents: Option<&str>) -> Option { + contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + } + + struct StaticFetcher { + contents: Option, + } + + #[async_trait::async_trait] + impl RequirementsFetcher for StaticFetcher { + async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option { + self.contents.clone() + } + } + + struct PendingFetcher; + + #[async_trait::async_trait] + impl RequirementsFetcher for PendingFetcher { + async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option { + pending::<()>().await; + None + } + } + + #[tokio::test] + async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { + let auth_manager = auth_manager_with_api_key(); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(StaticFetcher { contents: None }), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let result = service.fetch().await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_skips_non_enterprise_plan() { + let auth_manager = auth_manager_with_plan("pro"); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(StaticFetcher { contents: None }), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let result = service.fetch().await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_handles_missing_contents() { + let result = parse_for_fetch(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_sandbox_modes: None, + mcp_servers: None, + }) + ); + } + + #[tokio::test(start_paused = true)] + async fn fetch_cloud_requirements_times_out() { + let auth_manager = auth_manager_with_plan("enterprise"); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(PendingFetcher), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + 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"); + assert!(result.is_none()); + } +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bc7dba6d7..fbd899991 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -18,6 +18,7 @@ use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; use crate::config::types::Tui; use crate::config::types::UriBasedFileOpener; +use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::LoaderOverrides; @@ -366,6 +367,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + cloud_requirements: Option, fallback_cwd: Option, } @@ -390,6 +392,11 @@ impl ConfigBuilder { self } + pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { + self.cloud_requirements = Some(cloud_requirements); + self + } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { self.fallback_cwd = fallback_cwd; self @@ -401,6 +408,7 @@ impl ConfigBuilder { cli_overrides, harness_overrides, loader_overrides, + cloud_requirements, fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; @@ -413,9 +421,14 @@ impl ConfigBuilder { None => AbsolutePathBuf::current_dir()?, }; harness_overrides.cwd = Some(cwd.to_path_buf()); - let config_layer_stack = - load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides) - .await?; + let config_layer_stack = load_config_layers_state( + &codex_home, + Some(cwd), + &cli_overrides, + loader_overrides, + cloud_requirements, + ) + .await?; let merged_toml = config_layer_stack.effective_config(); // Note that each layer in ConfigLayerStack should have resolved @@ -511,6 +524,7 @@ pub async fn load_config_as_toml_with_cli_overrides( Some(cwd.clone()), &cli_overrides, LoaderOverrides::default(), + None, ) .await?; @@ -609,9 +623,14 @@ pub async fn load_global_mcp_servers( // There is no cwd/project context for this query, so this will not include // MCP servers defined in in-repo .codex/ folders. let cwd: Option = None; - let config_layer_stack = - load_config_layers_state(codex_home, cwd, &cli_overrides, LoaderOverrides::default()) - .await?; + let config_layer_stack = load_config_layers_state( + codex_home, + cwd, + &cli_overrides, + LoaderOverrides::default(), + None, + ) + .await?; let merged_toml = config_layer_stack.effective_config(); let Some(servers_value) = merged_toml.get("mcp_servers") else { return Ok(BTreeMap::new()); @@ -2612,7 +2631,8 @@ profile = "project" let cwd = AbsolutePathBuf::try_from(codex_home.path())?; let config_layer_stack = - load_config_layers_state(codex_home.path(), Some(cwd), &Vec::new(), overrides).await?; + load_config_layers_state(codex_home.path(), Some(cwd), &Vec::new(), overrides, None) + .await?; let cfg = deserialize_config_toml_with_base( config_layer_stack.effective_config(), codex_home.path(), @@ -2739,6 +2759,7 @@ profile = "project" Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, + None, ) .await?; diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 961c40e02..82583ee8a 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -376,6 +376,7 @@ impl ConfigService { cwd, &self.cli_overrides, self.loader_overrides.clone(), + None, ) .await } diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index 67170644f..04b72e4ca 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_core::config_loader`: -- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides) -> ConfigLayerStack` +- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -49,6 +49,7 @@ let layers = load_config_layers_state( Some(cwd), &cli_overrides, LoaderOverrides::default(), + None, ).await?; let effective = layers.effective_config(); diff --git a/codex-rs/core/src/config_loader/cloud_requirements.rs b/codex-rs/core/src/config_loader/cloud_requirements.rs new file mode 100644 index 000000000..7c5e3a294 --- /dev/null +++ b/codex-rs/core/src/config_loader/cloud_requirements.rs @@ -0,0 +1,56 @@ +use crate::config_loader::ConfigRequirementsToml; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use std::fmt; +use std::future::Future; + +#[derive(Clone)] +pub struct CloudRequirementsLoader { + // TODO(gt): This should return a Result once we can fail-closed. + fut: Shared>>, +} + +impl CloudRequirementsLoader { + pub fn new(fut: F) -> Self + where + F: Future> + Send + 'static, + { + Self { + fut: fut.boxed().shared(), + } + } + + pub async fn get(&self) -> Option { + self.fut.clone().await + } +} + +impl fmt::Debug for CloudRequirementsLoader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CloudRequirementsLoader").finish() + } +} + +#[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); + 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/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index a83398c71..2c220049c 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -13,6 +13,7 @@ use crate::config::ConstraintError; pub enum RequirementSource { Unknown, MdmManagedPreferences { domain: String, key: String }, + CloudRequirements, SystemRequirementsToml { file: AbsolutePathBuf }, LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf }, LegacyManagedConfigTomlFromMdm, @@ -25,6 +26,9 @@ impl fmt::Display for RequirementSource { RequirementSource::MdmManagedPreferences { domain, key } => { write!(f, "MDM {domain}:{key}") } + RequirementSource::CloudRequirements => { + write!(f, "cloud requirements") + } RequirementSource::SystemRequirementsToml { file } => { write!(f, "{}", file.as_path().display()) } @@ -448,6 +452,33 @@ mod tests { Ok(()) } + #[test] + fn constraint_error_includes_cloud_requirements_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + + let source_location = RequirementSource::CloudRequirements; + + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: source_location, + }) + ); + + Ok(()) + } + #[test] fn deserialize_allowed_approval_policies() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 5db10f280..0dd704cab 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -1,3 +1,4 @@ +mod cloud_requirements; mod config_requirements; mod diagnostics; mod fingerprint; @@ -30,6 +31,7 @@ use std::io; use std::path::Path; use toml::Value as TomlValue; +pub use cloud_requirements::CloudRequirementsLoader; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::McpServerIdentity; @@ -69,6 +71,7 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// earlier layer cannot be overridden by a later layer: /// /// - admin: managed preferences (*) +/// - cloud: managed cloud requirements /// - system `/etc/codex/requirements.toml` /// /// For backwards compatibility, we also load from @@ -98,6 +101,7 @@ pub async fn load_config_layers_state( cwd: Option, cli_overrides: &[(String, TomlValue)], overrides: LoaderOverrides, + cloud_requirements: Option, // TODO(gt): Once exec and app-server are wired up, we can remove the option. ) -> io::Result { let mut config_requirements_toml = ConfigRequirementsWithSources::default(); @@ -110,6 +114,13 @@ pub async fn load_config_layers_state( ) .await?; + if let Some(loader) = cloud_requirements + && let Some(requirements) = loader.get().await + { + config_requirements_toml + .merge_unset_fields(RequirementSource::CloudRequirements, requirements); + } + // Honor /etc/codex/requirements.toml. if cfg!(unix) { load_requirements_toml( diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 4b512c150..c213c0b2e 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -4,11 +4,15 @@ use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConfigToml; +use crate::config::ConstraintError; use crate::config::ProjectConfig; +use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLoadError; use crate::config_loader::ConfigRequirements; +use crate::config_loader::ConfigRequirementsToml; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; +use crate::config_loader::config_requirements::RequirementSource; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; use codex_protocol::config_types::TrustLevel; @@ -65,6 +69,7 @@ async fn returns_config_error_for_invalid_user_config_toml() { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await .expect_err("expected error"); @@ -94,6 +99,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() { Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect_err("expected error"); @@ -182,6 +188,7 @@ extra = true Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect("load config"); @@ -218,6 +225,7 @@ async fn returns_empty_when_all_layers_missing() { Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect("load layers"); @@ -315,6 +323,7 @@ flag = false Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect("load config"); @@ -354,6 +363,7 @@ allowed_sandbox_modes = ["read-only"] ), ), }, + None, ) .await?; @@ -414,6 +424,7 @@ allowed_approval_policies = ["never"] ), ), }, + None, ) .await?; @@ -472,6 +483,91 @@ allowed_approval_policies = ["never", "on-request"] Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_file = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_file, + r#" +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_sandbox_modes: None, + mcp_servers: None, + }, + ); + load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; + + assert_eq!( + config_requirements_toml + .allowed_approval_policies + .as_ref() + .map(|sourced| sourced.value.clone()), + Some(vec![AskForApproval::Never]) + ); + assert_eq!( + config_requirements_toml + .allowed_approval_policies + .as_ref() + .map(|sourced| sourced.source.clone()), + Some(RequirementSource::CloudRequirements) + ); + + Ok(()) +} + +#[tokio::test] +async fn load_config_layers_includes_cloud_requirements() -> 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_sandbox_modes: None, + mcp_servers: None, + }; + let expected = requirements.clone(); + let cloud_requirements = CloudRequirementsLoader::new(async move { Some(requirements) }); + + let layers = load_config_layers_state( + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + Some(cloud_requirements), + ) + .await?; + + assert_eq!( + layers.requirements_toml().allowed_approval_policies, + expected.allowed_approval_policies + ); + assert_eq!( + layers + .requirements() + .approval_policy + .can_set(&AskForApproval::OnRequest), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "OnRequest".into(), + allowed: "[Never]".into(), + requirement_source: RequirementSource::CloudRequirements, + }) + ); + + Ok(()) +} + #[tokio::test] async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { let tmp = tempdir()?; @@ -501,6 +597,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; @@ -632,6 +729,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; @@ -691,6 +789,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; let project_layers_untrusted: Vec<_> = layers_untrusted @@ -728,6 +827,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; let project_layers_unknown: Vec<_> = layers_unknown @@ -788,6 +888,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; let project_layers: Vec<_> = layers @@ -843,6 +944,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io Some(cwd), &cli_overrides, LoaderOverrides::default(), + None, ) .await?; @@ -884,6 +986,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 8aa698688..f71ecfffa 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -88,6 +88,7 @@ impl SkillsManager { Some(cwd_abs), &cli_overrides, LoaderOverrides::default(), + None, ) .await { diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 12d0055cd..3ec623330 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -241,6 +241,7 @@ async fn load_exec_policy() -> anyhow::Result { cwd, &cli_overrides, overrides, + None, ) .await?; diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 552455f51..b0c8fdfbc 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -31,9 +31,10 @@ pub(crate) async fn build_config_state() -> Result { let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let cli_overrides = Vec::new(); let overrides = LoaderOverrides::default(); - let config_layer_stack = load_config_layers_state(&codex_home, None, &cli_overrides, overrides) - .await - .context("failed to load Codex config")?; + let config_layer_stack = + load_config_layers_state(&codex_home, None, &cli_overrides, overrides, None) + .await + .context("failed to load Codex config")?; let cfg_path = codex_home.join(CONFIG_TOML_FILE); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 7f7fe51c5..d859aa945 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -31,6 +31,7 @@ codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } codex-chatgpt = { workspace = true } +codex-cloud-requirements = { workspace = true } codex-common = { workspace = true, features = [ "cli", "elapsed", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c9f307dbe..a8c6e2b88 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,6 +8,7 @@ use app::App; pub use app::AppExitInfo; pub use app::ExitReason; use codex_app_server_protocol::AuthMode; +use codex_cloud_requirements::cloud_requirements_loader; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; use codex_common::oss::ollama_chat_deprecation_notice; @@ -23,6 +24,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; +use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::format_config_error_with_source; use codex_core::find_thread_path_by_id_str; @@ -206,6 +208,17 @@ pub async fn run_main( } }; + let cloud_auth_manager = AuthManager::shared( + codex_home.to_path_buf(), + false, + config_toml.cli_auth_credentials_store.unwrap_or_default(), + ); + let chatgpt_base_url = config_toml + .chatgpt_base_url + .clone() + .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let cloud_requirements = cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url); + let model_provider_override = if cli.oss { let resolved = resolve_oss_provider( cli.oss_provider.as_deref(), @@ -257,7 +270,12 @@ pub async fn run_main( ..Default::default() }; - let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + let config = load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await; if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) { #[allow(clippy::print_stderr)] @@ -370,9 +388,16 @@ pub async fn run_main( .with(otel_tracing_layer) .try_init(); - run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app( + cli, + config, + overrides, + cli_kv_overrides, + cloud_requirements, + feedback, + ) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -380,6 +405,7 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, + cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -465,7 +491,12 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await + load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await } else { initial_config } @@ -633,6 +664,7 @@ async fn run_ratatui_app( load_config_or_exit_with_fallback_cwd( cli_kv_overrides.clone(), overrides.clone(), + cloud_requirements.clone(), fallback_cwd, ) .await @@ -816,19 +848,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, + cloud_requirements: CloudRequirementsLoader, ) -> Config { - load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, cloud_requirements, None) + .await } async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, + cloud_requirements: CloudRequirementsLoader, fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] match ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .cloud_requirements(cloud_requirements) .fallback_cwd(fallback_cwd) .build() .await