diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 1e33cee0b0..f1f93a1e48 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -29,6 +29,7 @@ use std::time::Duration; pub use codex_app_server::app_server_control_socket_path; pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +pub use codex_app_server::in_process::EmbeddedNetworkPolicy; pub use codex_app_server::in_process::InProcessServerEvent; use codex_app_server::in_process::InProcessStartArgs; use codex_app_server::in_process::LogDbLayer; @@ -182,6 +183,8 @@ pub struct InProcessClientStartArgs { pub strict_config: bool, /// Preloaded cloud config bundle provider. pub cloud_config_bundle: CloudConfigBundleLoader, + /// Policy shared with transports created by the embedder before startup. + pub embedded_network_policy: EmbeddedNetworkPolicy, /// 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. @@ -245,6 +248,7 @@ impl InProcessClientStartArgs { loader_overrides: self.loader_overrides, strict_config: self.strict_config, cloud_config_bundle: self.cloud_config_bundle, + embedded_network_policy: self.embedded_network_policy, thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: self.feedback, log_db: self.log_db, @@ -882,6 +886,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), feedback: CodexFeedback::new(), log_db: None, state_db: Some(state_db), @@ -2060,6 +2065,7 @@ mod tests { loader_overrides: LoaderOverrides::default(), strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ffb03c0ec5..e9db2b7eab 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -37,6 +37,11 @@ config/account reloads. Local requirements-file edits take effect on the next explicit reload or restart. Installing a new policy cancels requests that it no longer permits; a failed policy load blocks network traffic. +Embedded app-server installs the same policy-aware requirements loader for clients +it constructs. The embedding TUI and exec runtime install the same policy before +creating their telemetry providers, background HTTP clients, and executor +connections. TUI worktree cloud loaders retain that shared policy on reload. + # User verification cancellation (experimental) Local UI clients can cancel a native user-verification RPC by sending @@ -416,3 +421,36 @@ requests fail promptly and can be retried when sign-in succeeds. `model/list` also checks gateway authentication before returning cached models. If authentication fails after the provider configuration changes, it asks the client to restart Codex so the retained catalog and gateway sign-in use the same provider. + +## Application network policy + +Application policy uses the same managed TOML merge as agent-network requirements: +higher-priority layers override conflicting values, including `enabled` and each +domain permission, while non-conflicting domain entries are retained. Omitted +values inherit from lower layers. After merging, a present network block defaults +to `enabled = true` and an empty domain map, meaning no external destinations are +allowed. An effective `enabled = false` disables application destination policy. +Domain keys are exact ASCII names, normalized to lowercase without a trailing dot +before merging; wildcards, URLs, ports, invalid permissions, and duplicate +normalized names are rejected. +App-server enforces these rules for its HTTP and WebSocket traffic before route +resolution or connection work, including redirects and reused clients. An allow +entry permits only HTTPS or WSS to that exact host. Agent-network requirements +remain separate in `network`. + +App-server reloads effective requirements on explicit config or account reloads. +Local changes or read failures discovered on reload revoke active requests; +unchanged requirements preserve them. Failed policy loads block traffic until +requirements load successfully. Invalid request or project configuration does not +revoke unrelated traffic. Account changes revoke +outstanding requests and clients retaining the previous account's authorization. +Policy updates also stop active requests to newly denied destinations. Narrow +authentication and requirements-discovery clients use local requirements and +exact endpoint URLs while workspace policy is loading. API-key-only deployments +do not discover ChatGPT workspace requirements. + +SDK transports without destination enforcement, including OTLP exporters and AWS +credential discovery/signing, are disabled while restrictions apply. Supported +HTTP, WebSocket, and code-mode gRPC requests use the shared destination checks. +User-directed Git, SSH, shell, and other subprocess traffic retain their existing +execution and sandbox policies. diff --git a/codex-rs/app-server/src/application_network.rs b/codex-rs/app-server/src/application_network.rs index acd74d8eb6..0641a80e0b 100644 --- a/codex-rs/app-server/src/application_network.rs +++ b/codex-rs/app-server/src/application_network.rs @@ -12,7 +12,9 @@ use codex_http_client::NetworkPolicyDenied; use std::io; use std::sync::Arc; -fn destination_policy(application: Option<&ApplicationRequirementsToml>) -> DestinationPolicy { +pub(crate) fn destination_policy( + application: Option<&ApplicationRequirementsToml>, +) -> DestinationPolicy { match application.and_then(|application| application.network.as_ref()) { Some(network) if network.enabled => DestinationPolicy::Restricted { allowed_hosts: network diff --git a/codex-rs/app-server/src/application_network_tests.rs b/codex-rs/app-server/src/application_network_tests.rs index c6a513b45f..20a63a2d69 100644 --- a/codex-rs/app-server/src/application_network_tests.rs +++ b/codex-rs/app-server/src/application_network_tests.rs @@ -15,6 +15,151 @@ use tempfile::tempdir; use tokio::sync::Semaphore; use tokio::time::timeout; +#[tokio::test] +async fn embedded_transports_stay_blocked_until_the_same_policy_is_published() -> Result<()> { + use crate::in_process::EmbeddedNetworkPolicy; + use codex_core::config::ConfigBuilder; + use codex_http_client::ClientRouteClass; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200)) + .expect(2) + .mount(&server) + .await; + let home = tempdir()?; + let requirements = home.path().join("requirements.toml"); + std::fs::write(&requirements, "[application.network]")?; + let overrides = LoaderOverrides { + system_requirements_path: Some(requirements.clone()), + ..LoaderOverrides::without_managed_config_for_tests() + }; + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(overrides.clone()) + .build() + .await?; + config.chatgpt_base_url = server.uri(); + let cloud_endpoint = format!("{}/api/codex/config/bundle", server.uri()); + let policy = EmbeddedNetworkPolicy::load(&overrides).await; + let bootstrap_auth = policy.bind_bootstrap_auth(config.auth_config()); + let bootstrap = bootstrap_auth + .auth_route_config + .http_client_factory() + .build_client(&cloud_endpoint, ClientRouteClass::Auth)?; + let application = policy + .bind(config.http_client_factory()) + .build_client(&server.uri(), ClientRouteClass::Other)?; + assert!(bootstrap.get(&cloud_endpoint).send().await.is_err()); + assert!(application.get(server.uri()).send().await.is_err()); + policy.activate(&mut config); + let caller_factory = config.http_client_factory(); + let caller_client = caller_factory.build_client(&server.uri(), ClientRouteClass::Other)?; + let worktree_bootstrap = policy.clone().bind_bootstrap_auth(config.auth_config()); + let worktree_client = worktree_bootstrap + .auth_route_config + .http_client_factory() + .build_client(&cloud_endpoint, ClientRouteClass::Auth)?; + assert!(caller_client.get(server.uri()).send().await.is_err()); + assert!(worktree_client.get(&cloud_endpoint).send().await.is_err()); + assert!( + caller_factory + .network_policy() + .acquire_for_unsupported_sdk() + .is_err() + ); + + let manager = ConfigManager::new_for_tests( + home.path().to_path_buf(), + Vec::new(), + overrides, + CloudConfigBundleLoader::default(), + ) + .with_embedded_network_policy(policy); + drop(manager.refresh_application_network_policy().await?); + assert!(application.get(server.uri()).send().await.is_err()); + assert!(server.received_requests().await.unwrap().is_empty()); + + std::fs::remove_file(&requirements)?; + let unrestricted = manager.refresh_application_network_policy().await?; + let unrelated_endpoint = format!("{}/not-the-config-endpoint", server.uri()).parse()?; + assert_eq!( + bootstrap_auth + .auth_route_config + .http_client_factory() + .network_policy() + .acquire(&unrelated_endpoint) + .map(|_| ()), + Err(NetworkPolicyDenied::Destination), + ); + caller_client.get(server.uri()).send().await?; + worktree_client.get(&cloud_endpoint).send().await?; + assert!( + caller_factory + .network_policy() + .acquire_for_unsupported_sdk() + .is_ok() + ); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + std::fs::write(&requirements, "[malformed")?; + assert!(manager.refresh_application_network_policy().await.is_err()); + assert!(caller_client.get(server.uri()).send().await.is_err()); + assert!(worktree_client.get(&cloud_endpoint).send().await.is_err()); + assert!( + caller_factory + .network_policy() + .acquire_for_unsupported_sdk() + .is_err() + ); + assert!( + manager + .check_application_policy_load(&unrestricted) + .is_err() + ); + std::fs::remove_file(requirements)?; + let recovered = manager.refresh_application_network_policy().await?; + manager.check_application_policy_load(&recovered)?; + assert!( + manager + .check_application_policy_load(&unrestricted) + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn embedded_caller_activation_uses_cloud_requirements() -> Result<()> { + use crate::in_process::EmbeddedNetworkPolicy; + use codex_core::config::ConfigBuilder; + + let home = tempdir()?; + let overrides = LoaderOverrides::without_managed_config_for_tests(); + let policy = EmbeddedNetworkPolicy::load(&overrides).await; + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(overrides) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement("[application.network]"), + ) + .build() + .await?; + policy.activate(&mut config); + let factory = config.http_client_factory(); + assert!( + factory + .network_policy() + .acquire_for_unsupported_sdk() + .is_err() + ); + let url = "https://denied.example".parse()?; + assert!(factory.network_policy().acquire(&url).is_err()); + Ok(()) +} + #[tokio::test] async fn overlapping_policy_reloads_publish_in_order() -> Result<()> { let home = tempdir()?; diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index f952414820..2467d4d4f1 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -31,7 +31,7 @@ use tracing::instrument; use tracing::warn; #[path = "application_network.rs"] -mod application_network; +pub(crate) mod application_network; #[derive(Debug, thiserror::Error)] #[error( @@ -100,6 +100,15 @@ impl ConfigManager { self.codex_home.as_path() } + pub(crate) fn with_embedded_network_policy( + mut self, + policy: crate::in_process::EmbeddedNetworkPolicy, + ) -> Self { + self.network_policy = policy.effective; + self.local_network_policy = policy.local; + self + } + pub(crate) fn user_config_path(&self) -> std::io::Result { self.loader_overrides.user_config_path(self.codex_home()) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index e5896139f6..1fb7b51f17 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -67,6 +67,7 @@ use crate::plugin_config_reload::PluginStartupConfig; use crate::transport::CHANNEL_CAPACITY; use crate::transport::OutboundConnectionState; use crate::transport::route_outgoing_envelope; +pub use bootstrap::EmbeddedNetworkPolicy; use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::AgentMessageDelivery; use codex_app_server_protocol::ClientNotification; @@ -146,6 +147,8 @@ pub struct InProcessStartArgs { pub strict_config: bool, /// Preloaded cloud config bundle provider. pub cloud_config_bundle: CloudConfigBundleLoader, + /// Policy shared with transports created by the embedder before startup. + pub embedded_network_policy: EmbeddedNetworkPolicy, /// 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. @@ -430,7 +433,8 @@ async fn start_uninitialized(mut args: InProcessStartArgs) -> IoResult Self { + let policy = Self::default(); + let local = async { + codex_config::loader::load_local_application_requirements( + codex_exec_server::LOCAL_FS.as_ref(), + overrides, + ) + .await? + .compose(Default::default()) + } + .await; + if let Ok(application) = local { + policy.local.publish( + policy.local.policy().revision(), + crate::config_manager::application_network::destination_policy( + application.as_ref(), + ), + ); + } + policy + } + + /// Attaches the live application policy before an environment can start connecting. + pub fn bind( + &self, + factory: codex_http_client::HttpClientFactory, + ) -> codex_http_client::HttpClientFactory { + factory.with_network_policy(self.effective.policy()) + } + + /// Publishes successfully loaded requirements before caller-owned clients are created. + pub fn activate(&self, config: &mut Config) { + let application = config + .config_layer_stack + .requirements() + .application + .as_ref() + .map(|requirements| &requirements.value); + self.effective.publish( + self.effective.policy().revision(), + crate::config_manager::application_network::destination_policy(application), + ); + self.bind_config(config); + } + + /// Keeps a rebuilt caller config on the live policy owned by the running app-server. + pub fn bind_config(&self, config: &mut Config) { + config.application_network_policy = self.effective.policy(); + } + + /// Limits cloud discovery and each authentication request to their exact local-policy endpoints. + pub fn bind_bootstrap_auth( + &self, + mut auth: codex_login::AuthConfig, + ) -> codex_login::AuthConfig { + let factory = auth + .auth_route_config + .http_client_factory() + .clone() + .with_network_policy(self.local.policy()); + let endpoint = codex_backend_client::Client::new( + auth.chatgpt_base_url + .as_deref() + .unwrap_or("https://chatgpt.com/backend-api/"), + factory.clone(), + ) + .config_bundle_url(); + auth.auth_route_config = codex_login::AuthRouteConfig::from_http_client_factory( + factory.clone().with_network_policy( + self.local + .policy() + .restrict_to_endpoints(endpoint.parse().into_iter().collect()), + ), + ) + .with_local_bootstrap_factory(factory); + auth + } +} + pub(super) async fn configure( config_manager: &ConfigManager, config: &mut Arc, @@ -81,8 +169,12 @@ pub(super) async fn configure( config.chatgpt_base_url.clone(), config.http_client_factory(), ); - config_manager - .sync_default_client_residency_requirement() - .await; + codex_login::default_client::set_default_client_residency_requirement( + config.enforce_residency.value(), + ); Ok(auth_manager) } + +#[cfg(test)] +#[path = "in_process_bootstrap_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/in_process_bootstrap_tests.rs b/codex-rs/app-server/src/in_process_bootstrap_tests.rs new file mode 100644 index 0000000000..bbc0347971 --- /dev/null +++ b/codex-rs/app-server/src/in_process_bootstrap_tests.rs @@ -0,0 +1,85 @@ +//! Embedded startup must keep the project selected by the caller. + +use super::configure; +use crate::config_manager::ConfigManager; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudConfigBundleLoader; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use pretty_assertions::assert_eq; +use std::sync::Arc; + +#[tokio::test] +async fn startup_reloads_the_callers_selected_project() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + let root = temp.path().canonicalize()?; + let home = root.join("home"); + let selected = root.join("selected"); + std::fs::create_dir(&home)?; + std::fs::create_dir_all(selected.join(".codex"))?; + std::fs::create_dir(selected.join(".git"))?; + set_project_trust_level(&home, &selected, TrustLevel::Trusted)?; + let selected_url = "https://selected.example/backend-api/"; + let user_config = home.join("config.toml"); + let trust = std::fs::read_to_string(&user_config)?; + std::fs::write( + user_config, + format!("chatgpt_base_url = '{selected_url}'\n{trust}"), + )?; + let project_config = selected.join(".codex/config.toml"); + std::fs::write(&project_config, "model = 'selected-model'")?; + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let mut config = Arc::new( + ConfigBuilder::default() + .codex_home(home.clone()) + .harness_overrides(ConfigOverrides { + cwd: Some(selected.clone()), + ..Default::default() + }) + .loader_overrides(loader_overrides.clone()) + .build() + .await?, + ); + assert_eq!(config.chatgpt_base_url, selected_url); + assert_eq!(config.model.as_deref(), Some("selected-model")); + let manager = ConfigManager::new( + home, + Vec::new(), + loader_overrides, + /*strict_config*/ true, + CloudConfigBundleLoader::default(), + Arg0DispatchPaths::default(), + Arc::new(NoopThreadConfigLoader), + ); + let _auth = configure( + &manager, + &mut config, + /*enable_codex_api_key_env*/ false, + ) + .await?; + assert_eq!( + config.cwd.as_path().canonicalize()?, + selected.canonicalize()? + ); + assert_eq!(config.chatgpt_base_url, selected_url); + + std::fs::write(&project_config, "[malformed")?; + let error = configure( + &manager, + &mut config, + /*enable_codex_api_key_env*/ false, + ) + .await + .err() + .ok_or_else(|| anyhow::anyhow!("embedded startup ignored the selected project"))?; + assert!( + error + .to_string() + .contains("Error parsing project config file") + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index fbbd40651a..5c55734842 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -163,6 +163,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::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/account_system_proxy_tests.rs b/codex-rs/app-server/tests/suite/v2/account_system_proxy_tests.rs index 1770bc7086..f55e7ad8cf 100644 --- a/codex-rs/app-server/tests/suite/v2/account_system_proxy_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/account_system_proxy_tests.rs @@ -187,6 +187,7 @@ async fn browser_login_bootstraps_through_system_proxy() -> Result<()> { loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::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 e1b078fd17..f3b87e7e52 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -1216,6 +1216,7 @@ async fn start_resource_in_process_client( loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), thread_config_loader, feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_enforcement_tests.rs b/codex-rs/app-server/tests/suite/v2/model_provider_enforcement_tests.rs index a0b3f772af..15bd32e497 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_enforcement_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_enforcement_tests.rs @@ -321,6 +321,7 @@ async fn malformed_system_defaults_do_not_block_existing_thread_turn() -> Result loader_overrides: overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), thread_config_loader: Arc::new(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 523857eed4..ee43a62da9 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 @@ -533,6 +533,7 @@ async fn start_in_process_client( loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, 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 d7d326f910..06d0ec878d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -859,6 +859,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -929,6 +930,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -1037,6 +1039,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::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_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 3ca6046351..fcc328d28b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -297,6 +297,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::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/turn_cost_otel.rs b/codex-rs/app-server/tests/suite/v2/turn_cost_otel.rs index 48cfbe1d14..f9a06f2e47 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_cost_otel.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_cost_otel.rs @@ -148,6 +148,7 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{}/metrics", protocol = "json" loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), + embedded_network_policy: Default::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 2c8bbdc0db..4cffddd43c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -368,7 +368,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ignore_user_and_project_exec_policy_rules: ignore_rules, ..Default::default() }; - if worktree && EnvironmentManager::prepare_from_codex_home(&codex_home) .await? @@ -378,6 +377,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } let managed_worktree = if worktree { + let embedded_network_policy = + codex_app_server_client::EmbeddedNetworkPolicy::load(&loader_overrides).await; let gate_bootstrap = load_bootstrap_config_or_exit( &codex_home, /*cwd*/ None, @@ -388,7 +389,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ) .await; let gate_cloud_config = cloud_config_bundle_loader_for_storage( - bootstrap_auth_config(&codex_home, &gate_bootstrap)?, + embedded_network_policy + .bind_bootstrap_auth(bootstrap_auth_config(&codex_home, &gate_bootstrap)?), /*enable_codex_api_key_env*/ false, ) .await?; @@ -416,7 +418,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result &arg0_paths, &cli_kv_overrides, &loader_overrides, - gate_cloud_config.clone(), + worktree::ForkNetwork { + cloud_config_bundle: gate_cloud_config.clone(), + policy: embedded_network_policy.clone(), + }, strict_config, ) .await?; @@ -471,6 +476,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } else { None }; + let embedded_network_policy = + codex_app_server_client::EmbeddedNetworkPolicy::load(&loader_overrides).await; let bootstrap_config = load_bootstrap_config_or_exit( &codex_home, Some(&config_cwd), @@ -481,7 +488,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ) .await; let bootstrap_config_toml = &bootstrap_config.config_toml; - let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?; + let bootstrap_auth_config = embedded_network_policy + .bind_bootstrap_auth(bootstrap_auth_config(&codex_home, &bootstrap_config)?); // API keys cannot fetch workspace-managed configuration. Preserve the // existing ChatGPT bootstrap identity even when model requests allow // CODEX_API_KEY. @@ -600,12 +608,13 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .cloud_config_bundle(cloud_config_bundle.clone()) .build() }; - let config = build_exec_config( + let mut config = build_exec_config( overrides, dangerously_bypass_approvals_and_sandbox, build_config, ) .await?; + embedded_network_policy.activate(&mut config); let resume_approvals_reviewer_override = cli_kv_overrides .iter() .any(|(key, _)| key == "approvals_reviewer") @@ -687,13 +696,16 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ); let state_db = codex_core::init_state_db(&config).await; let environment_manager = if run_loader_overrides.ignore_user_config { - EnvironmentManager::from_env(Some(local_runtime_paths), config.http_client_factory()) - .await? + EnvironmentManager::from_env( + Some(local_runtime_paths), + embedded_network_policy.bind(config.http_client_factory()), + ) + .await? } else { EnvironmentManager::from_codex_home( config.codex_home.clone(), Some(local_runtime_paths), - config.http_client_factory(), + embedded_network_policy.bind(config.http_client_factory()), ) .await? }; @@ -704,6 +716,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result loader_overrides: run_loader_overrides, strict_config, cloud_config_bundle: run_cloud_config_bundle, + embedded_network_policy, feedback: CodexFeedback::new(), log_db: None, state_db: state_db.clone(), diff --git a/codex-rs/exec/src/worktree.rs b/codex-rs/exec/src/worktree.rs index d73e3ad639..05771ccfa9 100644 --- a/codex-rs/exec/src/worktree.rs +++ b/codex-rs/exec/src/worktree.rs @@ -3,13 +3,18 @@ use super::*; +pub(super) struct ForkNetwork { + pub cloud_config_bundle: CloudConfigBundleLoader, + pub policy: codex_app_server_client::EmbeddedNetworkPolicy, +} + pub(super) async fn fork_source( args: &mut crate::cli::ForkArgs, config: &Config, arg0_paths: &Arg0DispatchPaths, cli_overrides: &[(String, codex_config::TomlValue)], loader_overrides: &LoaderOverrides, - cloud_config_bundle: CloudConfigBundleLoader, + network: ForkNetwork, strict_config: bool, ) -> anyhow::Result { let state_db = codex_core::init_state_db(config).await; @@ -19,7 +24,7 @@ pub(super) async fn fork_source( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?), - config.http_client_factory(), + network.policy.bind(config.http_client_factory()), ) .await?; let client = InProcessAppServerClient::start(InProcessClientStartArgs { @@ -31,7 +36,8 @@ pub(super) async fn fork_source( ..loader_overrides.clone() }, strict_config, - cloud_config_bundle, + cloud_config_bundle: network.cloud_config_bundle, + embedded_network_policy: network.policy, feedback: CodexFeedback::new(), log_db: None, state_db: state_db.clone(), diff --git a/codex-rs/exec/tests/suite/auth_env.rs b/codex-rs/exec/tests/suite/auth_env.rs index 179f40fbd4..54d7391c84 100644 --- a/codex-rs/exec/tests/suite/auth_env.rs +++ b/codex-rs/exec/tests/suite/auth_env.rs @@ -4,7 +4,12 @@ use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex_exec::test_codex_exec; +use serde_json::json; +use wiremock::Mock; +use wiremock::ResponseTemplate; use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> { @@ -29,3 +34,162 @@ async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_api_key_cannot_discard_stored_workspace_network_policy() -> anyhow::Result<()> { + let test = test_codex_exec(); + let policy_server = start_mock_server().await; + let inference_server = start_mock_server().await; + let token = concat!( + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.", + "eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9wbGFuX3R5cGUiOiJlbnRlcnByaXNlIiwiY2hhdGdwdF9hY2NvdW50X2lkIjoid29ya3NwYWNlIn19.", + "c2lnbmF0dXJl", + ); + std::fs::write( + test.home_path().join("auth.json"), + serde_json::to_vec(&json!({ + "auth_mode": "chatgpt", + "tokens": {"id_token": token, "access_token": "workspace-token", + "refresh_token": "refresh-token", "account_id": "workspace"}, + "last_refresh": "2099-01-01T00:00:00Z", + }))?, + )?; + std::fs::write( + test.home_path().join("config.toml"), + format!( + "chatgpt_base_url = '{}/backend-api/'\ncli_auth_credentials_store = 'file'\n", + policy_server.uri() + ), + )?; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .and(header("authorization", "Bearer workspace-token")) + .and(header("chatgpt-account-id", "workspace")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "requirements_toml": {"enterprise_managed": [{ + "id": "workspace-network", "name": "Workspace network", + "contents": "[application.network]\nenabled = true", + }]}, + }))) + .expect(1..) + .mount(&policy_server) + .await; + let inference = mount_sse_once_match( + &inference_server, + header("Authorization", "Bearer dummy"), + sse(vec![ev_completed("unexpected-request")]), + ) + .await; + + test.cmd_with_server(&inference_server) + .arg("--skip-git-repo-check") + .arg("echo testing workspace network policy") + .assert() + .failure(); + + assert!( + inference.requests().is_empty(), + "an environment API key sent model traffic despite workspace policy" + ); + policy_server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_bootstrap_rejects_redirected_oauth_and_cloud_policy() -> anyhow::Result<()> { + for redirect_oauth in [true, false] { + let test = test_codex_exec(); + let origin = start_mock_server().await; + let destination = start_mock_server().await; + let inference = start_mock_server().await; + let token = concat!( + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.", + "eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9wbGFuX3R5cGUiOiJlbnRlcnByaXNlIiwiY2hhdGdwdF9hY2NvdW50X2lkIjoid29ya3NwYWNlIn19.", + "c2lnbmF0dXJl", + ); + let last_refresh = if redirect_oauth { + "2000-01-01T00:00:00Z" + } else { + "2099-01-01T00:00:00Z" + }; + std::fs::write( + test.home_path().join("auth.json"), + serde_json::to_vec(&json!({ + "auth_mode": "chatgpt", + "tokens": {"id_token": token, "access_token": "workspace-token", + "refresh_token": "refresh-token", "account_id": "workspace"}, + "last_refresh": last_refresh, + }))?, + )?; + std::fs::write( + test.home_path().join("config.toml"), + format!( + "chatgpt_base_url = '{}/backend-api/'\ncli_auth_credentials_store = 'file'\n", + origin.uri() + ), + )?; + let cloud_response = if redirect_oauth { + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(wiremock::matchers::body_string_contains("refresh-token")) + .respond_with( + ResponseTemplate::new(307) + .insert_header("location", format!("{}/stolen", destination.uri())), + ) + .expect(1..) + .mount(&origin) + .await; + Mock::given(method("POST")) + .and(path("/stolen")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id_token": token, "access_token": "replacement", "refresh_token": "replacement", + }))) + .mount(&destination) + .await; + ResponseTemplate::new(200).set_body_json(json!({})) + } else { + Mock::given(method("GET")) + .and(path("/policy")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "requirements_toml": {"enterprise_managed": [{ + "id": "redirected", "name": "Redirected policy", + "contents": "[application.network.domains]\n'foreign.example' = 'allow'", + }]}, + }))) + .mount(&destination) + .await; + ResponseTemplate::new(308) + .insert_header("location", format!("{}/policy", destination.uri())) + }; + let cloud = Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .respond_with(cloud_response); + if redirect_oauth { + cloud.mount(&origin).await; + } else { + cloud.expect(1..).mount(&origin).await; + } + mount_sse_once_match( + &inference, + header("Authorization", "Bearer dummy"), + sse(vec![ev_completed("request")]), + ) + .await; + + let output = test + .cmd_with_server(&inference) + .env( + codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + format!("{}/oauth/token", origin.uri()), + ) + .arg("--skip-git-repo-check") + .arg("exercise embedded discovery") + .output()?; + if !redirect_oauth { + assert!(!output.status.success()); + } + origin.verify().await; + assert!(destination.received_requests().await.unwrap().is_empty()); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index e6d4136046..d04b74576d 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -9,11 +9,19 @@ use codex_config::ConfigLayerSource; async fn build_config_on_runtime_worker( builder: ConfigBuilder, + application_network_policy: codex_http_client::NetworkPolicy, error_context: String, ) -> Result { // Tokio stores the task output inline even when it boxes the future. Keep the large // Config off the caller's stack while Tokio allocates the task during session switches. - match tokio::spawn(async move { builder.build().await.map(Box::new) }).await { + match tokio::spawn(async move { + builder.build().await.map(|mut config| { + config.application_network_policy = application_network_policy; + Box::new(config) + }) + }) + .await + { Ok(build_result) => build_result.map(|config| *config).wrap_err(error_context), Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()), Err(err) => Err(err).wrap_err_with(|| format!("{error_context} task failed")), @@ -92,6 +100,7 @@ impl App { .cloud_config_bundle(self.cloud_config_bundle.clone()); build_config_on_runtime_worker( builder, + self.config.application_network_policy.clone(), format!("Failed to rebuild config for cwd {cwd_display}"), ) .await @@ -114,6 +123,7 @@ impl App { .cloud_config_bundle(self.cloud_config_bundle.clone()); build_config_on_runtime_worker( builder, + self.config.application_network_policy.clone(), format!("Failed to rebuild config for permission profile {profile_id}"), ) .await @@ -1705,6 +1715,78 @@ enabled = false Ok(()) } + #[tokio::test] + async fn new_session_and_config_rebuilds_keep_the_live_application_network_policy() -> Result<()> + { + use codex_http_client::ClientRouteClass; + use codex_http_client::DestinationPolicy; + use codex_http_client::HttpError; + use codex_http_client::NetworkPolicyController; + use codex_http_client::NetworkPolicyDenied; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + let controller = NetworkPolicyController::default(); + let denied = DestinationPolicy::Restricted { + allowed_hosts: Default::default(), + }; + assert!(controller.publish(controller.policy().revision(), denied.clone())); + app.config.application_network_policy = controller.policy(); + let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + app.refresh_in_memory_config_from_disk().await?; + let new_config = app.load_new_session_config(&app_server).await?; + let permission_config = app + .rebuild_config_for_permission_profile(":workspace") + .await?; + let pet_url = + "https://persistent.oaistatic.com/codex/pets/v1/dewey-spritesheet-v4.webp".parse()?; + for config in [&app.config, &new_config, &permission_config] { + assert_eq!(config.application_network_policy, controller.policy()); + assert_eq!( + config + .http_client_factory() + .network_policy() + .acquire(&pet_url) + .map(|_| ()), + Err(NetworkPolicyDenied::Destination), + ); + } + let client = new_config + .http_client_factory() + .build_client(&server.uri(), ClientRouteClass::Other)?; + assert!(matches!( + client.get(server.uri()).send().await, + Err(HttpError::Policy(NetworkPolicyDenied::Destination)) + )); + assert!(server.received_requests().await.unwrap().is_empty()); + + assert!(controller.publish( + controller.policy().revision(), + DestinationPolicy::Unrestricted + )); + assert!(client.get(server.uri()).send().await?.status().is_success()); + assert!(controller.publish(controller.policy().revision(), denied)); + assert!(matches!( + client.get(server.uri()).send().await, + Err(HttpError::Policy(NetworkPolicyDenied::Destination)) + )); + server.verify().await; + app_server.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> { diff --git a/codex-rs/tui/src/app/experimental_features_tests.rs b/codex-rs/tui/src/app/experimental_features_tests.rs index ed67102309..0b8920c13b 100644 --- a/codex-rs/tui/src/app/experimental_features_tests.rs +++ b/codex-rs/tui/src/app/experimental_features_tests.rs @@ -35,6 +35,7 @@ async fn experimental_features_use_selected_server_profile_and_preserve_task_set /*log_db*/ None, /*state_db*/ None, Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), )) .await?; let mut server = AppServerSession::new( diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index e500ffe5b2..0d61ab6aed 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -588,6 +588,7 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< /*log_db*/ None, /*state_db*/ None, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + Default::default(), ) .await?; let mut server = AppServerSession::new( diff --git a/codex-rs/tui/src/app/tests/model_defaults_tests.rs b/codex-rs/tui/src/app/tests/model_defaults_tests.rs index 477fdc1421..882196edeb 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -136,6 +136,7 @@ async fn model_default_saves_report_server_outcomes_and_target_server_profile() /*log_db*/ None, /*state_db*/ None, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + Default::default(), ) .await?; let mut server = AppServerSession::new( diff --git a/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs b/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs index cc71c03840..111ff663b6 100644 --- a/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs +++ b/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs @@ -35,6 +35,7 @@ async fn permission_shortcut_rejections_leave_state_unchanged() -> Result<()> { /*log_db*/ None, /*state_db*/ None, Arc::clone(&app.environment_manager), + Default::default(), |mut args| { args.experimental_api = experimental_api; codex_app_server_client::InProcessAppServerClient::start(args) diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs index 8acbc128f9..af57f9a22b 100644 --- a/codex-rs/tui/src/app/tests/realtime_requests.rs +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -1967,6 +1967,7 @@ async fn overridden_voice_save_keeps_effective_voice() -> Result<()> { /*log_db*/ None, /*state_db*/ None, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + Default::default(), ) .await?; let server = AppServerSession::new( diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 46357d659a..c183e79eca 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -276,6 +276,7 @@ pub(super) async fn start_recording_app_server_with_realtime_speech( /*log_db*/ None, state_db, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + Default::default(), ) .await?; let codex_home = config.codex_home.display().to_string(); diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 201ec0fde0..8d0aa63535 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -321,6 +321,7 @@ async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre:: /*log_db*/ None, &mut state_db, Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), ) .await; reject_handshake.abort(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e37d17ca94..2d8cfd31fc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -287,6 +287,7 @@ async fn start_embedded_app_server( log_db: Option, state_db: Option, environment_manager: Arc, + embedded_network_policy: codex_app_server_client::EmbeddedNetworkPolicy, ) -> color_eyre::Result { start_embedded_app_server_with( arg0_paths, @@ -299,6 +300,7 @@ async fn start_embedded_app_server( log_db, state_db, environment_manager, + embedded_network_policy, InProcessAppServerClient::start, ) .await @@ -321,6 +323,37 @@ impl AppServerTarget { matches!(self, Self::Remote { .. }) } + fn uses_embedded_network_policy(&self) -> bool { + matches!( + self, + Self::Embedded + | Self::LocalDaemon { + allow_embedded_fallback: true, + .. + } + ) + } + + fn environment_http_client_factory( + &self, + config: &Config, + policy: &codex_app_server_client::EmbeddedNetworkPolicy, + ) -> codex_http_client::HttpClientFactory { + let factory = config.http_client_factory(); + match self { + Self::Embedded + | Self::LocalDaemon { + allow_embedded_fallback: true, + .. + } => policy.bind(factory), + Self::LocalDaemon { + allow_embedded_fallback: false, + .. + } + | Self::Remote { .. } => factory, + } + } + fn auth_config_for_cloud_loader(&self, mut auth_config: AuthConfig) -> AuthConfig { if self.uses_remote_workspace() { // Remove local auth restrictions before loading credentials for a remote @@ -521,6 +554,7 @@ async fn start_app_server( log_db: Option, state_db: &mut Option, environment_manager: Arc, + embedded_network_policy: codex_app_server_client::EmbeddedNetworkPolicy, ) -> color_eyre::Result { let connection = if matches!(target, AppServerTarget::Embedded) { None @@ -563,6 +597,7 @@ async fn start_app_server( log_db, state_db.clone(), environment_manager, + embedded_network_policy, ) .await .map(AppServerClient::InProcess) @@ -578,6 +613,8 @@ pub(crate) async fn start_app_server_for_picker( ) -> color_eyre::Result { let mut target = target.clone(); let mut state_db = state_db; + let embedded_network_policy = + codex_app_server_client::EmbeddedNetworkPolicy::load(&loader_overrides).await; let app_server = start_app_server( &mut target, Arg0DispatchPaths::default(), @@ -590,6 +627,7 @@ pub(crate) async fn start_app_server_for_picker( /*log_db*/ None, &mut state_db, environment_manager, + embedded_network_policy, ) .await?; Ok( @@ -616,6 +654,7 @@ pub(crate) async fn start_embedded_app_server_for_picker( /*log_db*/ None, &mut state_db, Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), ) .await?; Ok( @@ -636,6 +675,7 @@ async fn start_embedded_app_server_with( log_db: Option, state_db: Option, environment_manager: Arc, + embedded_network_policy: codex_app_server_client::EmbeddedNetworkPolicy, start_client: F, ) -> color_eyre::Result where @@ -659,6 +699,7 @@ where loader_overrides, strict_config, cloud_config_bundle, + embedded_network_policy, feedback, log_db, state_db, @@ -1002,10 +1043,13 @@ async fn cloud_config_bundle_for_app_server_target( app_server_target: &AppServerTarget, bootstrap_config: &ConfigTomlLoadResult, codex_home: &Path, + embedded_network_policy: &codex_app_server_client::EmbeddedNetworkPolicy, ) -> std::io::Result { cloud_config_bundle_loader_for_storage( - app_server_target - .auth_config_for_cloud_loader(bootstrap_auth_config(codex_home, bootstrap_config)?), + embedded_network_policy + .bind_bootstrap_auth(app_server_target.auth_config_for_cloud_loader( + bootstrap_auth_config(codex_home, bootstrap_config)?, + )), /*enable_codex_api_key_env*/ false, ) .await @@ -1094,6 +1138,7 @@ async fn run_ratatui_app( log_db: Option, mut state_db: Option, environment_manager: Arc, + embedded_network_policy: codex_app_server_client::EmbeddedNetworkPolicy, managed_worktree: Option, daemon_startup_warning: Option, launch_telemetry: daemon_telemetry::Launch, @@ -1159,6 +1204,7 @@ async fn run_ratatui_app( log_db.clone(), &mut state_db, environment_manager.clone(), + embedded_network_policy.clone(), ), ) .await; @@ -1243,7 +1289,7 @@ async fn run_ratatui_app( should_show_trust_screen_flag, ); - let config = if should_show_onboarding { + let mut config = if should_show_onboarding { if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; return Err(err.into()); @@ -1308,7 +1354,7 @@ async fn run_ratatui_app( // policy due to login status detection edge cases. if show_login_screen && !uses_remote_workspace && !workload_identity_selected { cloud_config_bundle = cloud_config_bundle_loader_for_storage( - initial_config.auth_config(), + embedded_network_policy.bind_bootstrap_auth(initial_config.auth_config()), /*enable_codex_api_key_env*/ false, ) .await?; @@ -1345,6 +1391,9 @@ async fn run_ratatui_app( } else { initial_config }; + if app_server_target.uses_embedded_network_policy() { + embedded_network_policy.bind_config(&mut config); + } startup_draft.apply_config(&config); if !(cli.resume_picker || cli.fork_picker || cli.agents_overview) && let Err(err) = startup_draft.show(&mut tui) @@ -1694,6 +1743,9 @@ async fn run_ratatui_app( return Err(err.into()); } }; + if app_server_target.uses_embedded_network_policy() { + embedded_network_policy.bind_config(&mut config); + } startup_draft.apply_config(&config); if config.model_provider_id != startup_model_provider { @@ -1721,6 +1773,7 @@ async fn run_ratatui_app( log_db.clone(), &mut state_db, environment_manager.clone(), + embedded_network_policy.clone(), ), ) .await @@ -1788,6 +1841,9 @@ async fn run_ratatui_app( managed_worktree.as_ref(), ) .await; + if app_server_target.uses_embedded_network_policy() { + embedded_network_policy.bind_config(&mut config); + } if config.model_provider_id != previous_provider && matches!(app_server_target, AppServerTarget::Embedded) { @@ -1804,6 +1860,7 @@ async fn run_ratatui_app( log_db.clone(), &mut state_db, environment_manager.clone(), + embedded_network_policy.clone(), ) .await?; app_server = AppServerSession::new(client, app_server_target.thread_params_mode()) @@ -1843,6 +1900,9 @@ async fn run_ratatui_app( startup_draft.update_session_selection(&mut tui, &session_selection)?; } } + if app_server_target.uses_embedded_network_policy() { + embedded_network_policy.bind_config(&mut config); + } startup_draft.apply_config(&config); // Count launches that reach final config resolution, regardless of screen policy. @@ -2595,6 +2655,7 @@ requires_openai_auth = {requires_openai_auth} /*log_db*/ None, state_db, Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), ) .await } @@ -3759,6 +3820,7 @@ requires_openai_auth = {requires_openai_auth} /*log_db*/ None, /*state_db*/ None, Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), |_args| async { Err(std::io::Error::other("boom")) }, ) .await; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index d242732ca8..c78840b6a2 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1143,6 +1143,7 @@ mod tests { ) .await .expect("test cloud config loader"), + embedded_network_policy: Default::default(), feedback: codex_feedback::CodexFeedback::new(), log_db: None, state_db: None, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 917a5e42f7..f117254468 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -298,6 +298,8 @@ pub(super) async fn start_app_server_for_session_command( loader_overrides.user_config_profile = Some(profile_v2.clone()); } loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace(); + let embedded_network_policy = + codex_app_server_client::EmbeddedNetworkPolicy::load(&loader_overrides).await; let bootstrap_config = load_config_toml_with_layer_stack( codex_home.as_path(), @@ -316,6 +318,7 @@ pub(super) async fn start_app_server_for_session_command( &app_server_target, &bootstrap_config, codex_home.as_path(), + &embedded_network_policy, ) .await?; @@ -356,7 +359,11 @@ pub(super) async fn start_app_server_for_session_command( .wrap_err("failed to load configuration")?; let environment_manager = Arc::new( prepared_environment_manager - .build(Some(local_runtime_paths), config.http_client_factory()) + .build( + Some(local_runtime_paths), + app_server_target + .environment_http_client_factory(&config, &embedded_network_policy), + ) .wrap_err("failed to initialize environment manager")?, ); let mut state_db = super::init_state_db_for_app_server_target(&config, &app_server_target) @@ -374,6 +381,7 @@ pub(super) async fn start_app_server_for_session_command( /*log_db*/ None, &mut state_db, environment_manager, + embedded_network_policy, ) .await?; Ok( diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 633be7780e..bc58e93edc 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -99,6 +99,8 @@ pub(super) async fn run_main_inner( launch_loader_overrides.user_config_path = Some(user_config_path); launch_loader_overrides.user_config_profile = Some(profile_v2.clone()); } + let embedded_network_policy = + codex_app_server_client::EmbeddedNetworkPolicy::load(&launch_loader_overrides).await; let workload_identity_selected = is_workload_identity_selected(); if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { @@ -138,6 +140,7 @@ pub(super) async fn run_main_inner( &validation_target, &validation_bootstrap, &codex_home, + &embedded_network_policy, ) .await? } else { @@ -321,6 +324,7 @@ pub(super) async fn run_main_inner( &app_server_target, &bootstrap_config, &codex_home, + &embedded_network_policy, )) .await??; let bootstrap_config_toml = &bootstrap_config.config_toml; @@ -430,6 +434,9 @@ pub(super) async fn run_main_inner( strict_config, )) .await?; + if app_server_target.uses_embedded_network_policy() { + embedded_network_policy.activate(&mut config); + } startup_draft.apply_config(&config); let mut cloud_config_bundle = if workload_identity_selected { @@ -437,7 +444,9 @@ pub(super) async fn run_main_inner( } else { startup_draft .run_until(cloud_config_bundle_loader_for_storage( - app_server_target.auth_config_for_cloud_loader(config.auth_config()), + embedded_network_policy.bind_bootstrap_auth( + app_server_target.auth_config_for_cloud_loader(config.auth_config()), + ), /*enable_codex_api_key_env*/ false, )) .await?? @@ -454,11 +463,15 @@ pub(super) async fn run_main_inner( &app_server_target, &arg0_paths, cloud_config_bundle.clone(), + &embedded_network_policy, )) .await? .map_err(|err| std::io::Error::other(err.to_string()))?; config = destination; cloud_config_bundle = bundle; + if app_server_target.uses_embedded_network_policy() { + embedded_network_policy.activate(&mut config); + } startup_draft.apply_config(&config); Some(worktree) } else { @@ -533,6 +546,9 @@ pub(super) async fn run_main_inner( app_server_target = AppServerTarget::Embedded; daemon_exclusion = Some("daemon feature settings"); } + if app_server_target.uses_embedded_network_policy() { + embedded_network_policy.activate(&mut config); + } let daemon_startup_warning = compatibility_warning.or_else(|| { daemon_exclusion .filter(|_| auto_start_daemon) @@ -548,7 +564,11 @@ pub(super) async fn run_main_inner( ); let environment_manager = Arc::new( prepared_environment_manager - .build(Some(local_runtime_paths), config.http_client_factory()) + .build( + Some(local_runtime_paths), + app_server_target + .environment_http_client_factory(&config, &embedded_network_policy), + ) .map_err(std::io::Error::other)?, ); @@ -829,6 +849,7 @@ pub(super) async fn run_main_inner( log_db, state_db, environment_manager, + embedded_network_policy, managed_worktree.clone(), daemon_startup_warning, launch_telemetry, diff --git a/codex-rs/tui/src/temporary_structured_request_tests.rs b/codex-rs/tui/src/temporary_structured_request_tests.rs index e0bcc101fb..36915c4bc4 100644 --- a/codex-rs/tui/src/temporary_structured_request_tests.rs +++ b/codex-rs/tui/src/temporary_structured_request_tests.rs @@ -100,6 +100,7 @@ async fn managed_workspace_default_respects_read_only_availability() -> color_ey /*log_db*/ None, /*state_db*/ None, Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), ) .await?; let app_server = AppServerClient::InProcess(client); diff --git a/codex-rs/tui/src/worktree_startup.rs b/codex-rs/tui/src/worktree_startup.rs index a576ec52b0..a3e1f3b3a4 100644 --- a/codex-rs/tui/src/worktree_startup.rs +++ b/codex-rs/tui/src/worktree_startup.rs @@ -126,6 +126,7 @@ pub(super) async fn prepare( target: &AppServerTarget, arg0_paths: &Arg0DispatchPaths, source_bundle: CloudConfigBundleLoader, + embedded_network_policy: &codex_app_server_client::EmbeddedNetworkPolicy, ) -> color_eyre::Result<(Config, CloudConfigBundleLoader, ManagedTuiWorktree)> { if let Some(id_or_name) = cli.fork_session_id.as_deref() { let prepared = if should_load_configured_environments(&loader_overrides, target) { @@ -142,7 +143,7 @@ pub(super) async fn prepare( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?), - source.http_client_factory(), + embedded_network_policy.bind(source.http_client_factory()), )?; let state = init_state_db_for_app_server_target(&source, &AppServerTarget::Embedded).await?; @@ -159,6 +160,7 @@ pub(super) async fn prepare( /*log_db*/ None, state, Arc::new(environment), + embedded_network_policy.clone(), ) .await?; let mut lookup = AppServerSession::new( @@ -190,9 +192,13 @@ pub(super) async fn prepare( CloudConfigBundleLoader::default(), ) .await; - let source_bundle = - cloud_config_bundle_for_app_server_target(target, &bootstrap, &source.codex_home) - .await?; + let source_bundle = cloud_config_bundle_for_app_server_target( + target, + &bootstrap, + &source.codex_home, + embedded_network_policy, + ) + .await?; overrides.cwd = Some(cwd.into_path_buf()); source = load_config_or_exit( cli_overrides.clone(), @@ -263,8 +269,13 @@ pub(super) async fn prepare( }, ) .await?; - let bundle = - cloud_config_bundle_for_app_server_target(target, &bootstrap, &source.codex_home).await?; + let bundle = cloud_config_bundle_for_app_server_target( + target, + &bootstrap, + &source.codex_home, + embedded_network_policy, + ) + .await?; managed .check_source_policy( &cli_overrides,