diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b4798737fa..9d999767b6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7482,6 +7482,40 @@ "AppToolsConfig": { "type": "object" }, + "ApplicationNetworkRequirements": { + "properties": { + "domains": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkDomainPermission" + }, + "type": "object" + }, + "enabled": { + "description": "When enabled, only explicitly allowed exact domains may be contacted.", + "type": "boolean" + } + }, + "required": [ + "domains", + "enabled" + ], + "type": "object" + }, + "ApplicationRequirements": { + "properties": { + "network": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApplicationNetworkRequirements" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 010719d896..98cc9380dc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -966,6 +966,40 @@ "AppToolsConfig": { "type": "object" }, + "ApplicationNetworkRequirements": { + "properties": { + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "type": "object" + }, + "enabled": { + "description": "When enabled, only explicitly allowed exact domains may be contacted.", + "type": "boolean" + } + }, + "required": [ + "domains", + "enabled" + ], + "type": "object" + }, + "ApplicationRequirements": { + "properties": { + "network": { + "anyOf": [ + { + "$ref": "#/definitions/ApplicationNetworkRequirements" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index f61c4ad2b2..fb5cc7029b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -8,6 +8,40 @@ ], "type": "string" }, + "ApplicationNetworkRequirements": { + "properties": { + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "type": "object" + }, + "enabled": { + "description": "When enabled, only explicitly allowed exact domains may be contacted.", + "type": "boolean" + } + }, + "required": [ + "domains", + "enabled" + ], + "type": "object" + }, + "ApplicationRequirements": { + "properties": { + "network": { + "anyOf": [ + { + "$ref": "#/definitions/ApplicationNetworkRequirements" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index b1c0ed239f..4befd79c2b 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index fe3b2f464b..d7e344bebf 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ApplicationNetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ApplicationNetworkRequirements.ts new file mode 100644 index 0000000000..5a10cdae76 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ApplicationNetworkRequirements.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkDomainPermission } from "./NetworkDomainPermission"; + +export type ApplicationNetworkRequirements = { +/** + * When enabled, only explicitly allowed exact domains may be contacted. + */ +enabled: boolean, domains: { [key in string]?: NetworkDomainPermission }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ApplicationRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ApplicationRequirements.ts new file mode 100644 index 0000000000..65a0626fa2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ApplicationRequirements.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ApplicationNetworkRequirements } from "./ApplicationNetworkRequirements"; + +export type ApplicationRequirements = { network: ApplicationNetworkRequirements | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 146a0fccfa..a3595e7769 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -31,6 +31,8 @@ export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReaso export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolSummary } from "./AppToolSummary"; export type { AppToolsConfig } from "./AppToolsConfig"; +export type { ApplicationNetworkRequirements } from "./ApplicationNetworkRequirements"; +export type { ApplicationRequirements } from "./ApplicationRequirements"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; export type { AppsConfig } from "./AppsConfig"; export type { AppsDefaultConfig } from "./AppsDefaultConfig"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/application.rs b/codex-rs/app-server-protocol/src/protocol/v2/application.rs new file mode 100644 index 0000000000..5e148814a3 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/application.rs @@ -0,0 +1,24 @@ +//! Managed requirements for application traffic, separate from agent permissions. + +use super::NetworkDomainPermission; +use crate::JsonSchema; +use crate::TS; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ApplicationRequirements { + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ApplicationNetworkRequirements { + /// When enabled, only explicitly allowed exact domains may be contacted. + pub enabled: bool, + pub domains: BTreeMap, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index aade147244..ad8e1bcd8d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -1,3 +1,4 @@ +use super::ApplicationRequirements; use super::ApprovalsReviewer; use super::AskForApproval; use super::BrowserUseConfig; @@ -432,6 +433,8 @@ pub struct ConfigRequirements { pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, + #[experimental("configRequirements/read.application")] + pub application: Option, pub auto_review: Option, pub models: Option, #[schemars(with = "Option")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 75416d13a2..240db7ef27 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -1,6 +1,7 @@ mod shared; mod account; +mod application; mod apps; mod attestation; mod bedrock; @@ -35,6 +36,7 @@ mod turn; mod windows_sandbox; pub use account::*; +pub use application::*; pub use apps::*; pub use attestation::*; pub use bedrock::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2870fbbb43..eed9a7ddc4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2112,6 +2112,7 @@ fn config_approvals_reviewer_is_marked_experimental() { fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { + application: None, cli_auth_credentials_store: None, chatgpt_base_url: None, additional_developer_instructions: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a07418049b..46f9f73b5b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -322,6 +322,20 @@ metadata in `data._meta`. Other operation failures retain the existing internal-error response. Tool results with `isError: true` remain results, including their `_meta`. +### Application requirements + +With experimental API support enabled, `configRequirements/read` returns +`application.network` from managed requirements, separately from agent-network +policy in `network`. This endpoint reports policy; it does not enforce it. + +```toml +[application.network.domains] +"managed.example.com" = "allow" +``` + +Application rules use normal managed TOML precedence. A present network block +is enabled by default and denies unlisted domains; `enabled = false` disables it. + ### Plugin configuration scope Plugin activation and MCP settings use the existing merged configuration, including diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 61eb09492a..7eda9def9f 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -381,6 +381,22 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .and_then(|windows| windows.sandbox_private_desktop); ConfigRequirements { + application: requirements.application.map(|application| { + codex_app_server_protocol::ApplicationRequirements { + network: application.network.map(|network| { + codex_app_server_protocol::ApplicationNetworkRequirements { + enabled: network.enabled, + domains: network + .domains + .into_iter() + .map(|(domain, permission)| { + (domain, map_network_domain_permission_to_api(permission)) + }) + .collect(), + } + }), + } + }), cli_auth_credentials_store: requirements.cli_auth_credentials_store.map( |mode| match mode { codex_config::types::AuthCredentialsStoreMode::File => { diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_application.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_application.rs new file mode 100644 index 0000000000..635bc2066b --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_application.rs @@ -0,0 +1,118 @@ +//! Public requirements responses expose managed application policy. + +use anyhow::Result; +use app_test_support::TestAppServer; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60); + +async fn start_server(home: &TempDir) -> Result { + TestAppServer::builder() + .with_codex_home(home.path()) + .build_initialized_with_timeout(READ_TIMEOUT) + .await +} + +async fn read_requirements(server: &mut TestAppServer) -> Result { + let request_id = server.send_config_requirements_read_request().await?; + timeout(READ_TIMEOUT, server.read_response(request_id)).await? +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_network_defaults_and_null_base_url_are_exposed() -> Result<()> { + for (contents, application) in [ + ("", Value::Null), + ("[application]", json!({"network": null})), + ( + "[application.network]", + json!({"network": {"enabled": true, "domains": {}}}), + ), + ( + "[application.network]\nenabled = false", + json!({"network": {"enabled": false, "domains": {}}}), + ), + ] { + let home = TempDir::new()?; + std::fs::write( + home.path().join("requirements.toml"), + format!("allow_remote_control = false\n{contents}"), + )?; + let mut server = start_server(&home).await?; + let wire = read_requirements(&mut server).await?; + let requirements = wire["requirements"] + .as_object() + .expect("requirements object"); + assert_eq!( + ( + requirements.get("application"), + requirements.get("chatgptBaseUrl") + ), + (Some(&application), Some(&Value::Null)), + ); + } + let home = TempDir::new()?; + let mut server = start_server(&home).await?; + assert_eq!( + read_requirements(&mut server).await?, + json!({"requirements": null}) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_destinations_stay_separate_from_agent_network() -> Result<()> { + let home = TempDir::new()?; + std::fs::write( + home.path().join("requirements.toml"), + r#" +chatgpt_base_url = "https://managed.example.com/backend-api/" +[experimental_network.domains] +"github.com" = "allow" +[application.network.domains] +"GOV.example.com." = "allow" +"github.com" = "deny" +"#, + )?; + let mut server = start_server(&home).await?; + let wire = read_requirements(&mut server).await?; + assert_eq!( + ( + &wire["requirements"]["application"], + &wire["requirements"]["network"]["domains"], + &wire["requirements"]["chatgptBaseUrl"] + ), + ( + &json!({"network": {"enabled": true, "domains": {"gov.example.com": "allow", "github.com": "deny"}}}), + &json!({"github.com": "allow"}), + &json!("https://managed.example.com/backend-api/") + ), + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_requirements_read_rejects_invalid_configuration() -> Result<()> { + let home = TempDir::new()?; + let mut server = start_server(&home).await?; + std::fs::write( + home.path().join("requirements.toml"), + "[application.network.domains]\n'*.example.com' = 'allow'", + )?; + let request_id = server.send_config_requirements_read_request().await?; + let error = timeout( + READ_TIMEOUT, + server.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert!( + error.error.message.contains("application.network.domains"), + "{error:?}" + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 27e7fb00d3..199a9bfb10 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -13,6 +13,7 @@ mod collaboration_mode_list; #[cfg(unix)] mod command_exec; mod compaction; +mod config_requirements_application; mod config_requirements_in_app_browser; mod config_rpc; mod connection_handling_websocket; diff --git a/codex-rs/config/src/application_requirements.rs b/codex-rs/config/src/application_requirements.rs new file mode 100644 index 0000000000..e86b03af8d --- /dev/null +++ b/codex-rs/config/src/application_requirements.rs @@ -0,0 +1,71 @@ +//! Managed application destinations are independent of agent network permissions. +//! Enabled policies deny unlisted domains and use normal managed TOML precedence. + +use crate::NetworkDomainPermissionToml; +use serde::Deserialize; +use serde::de::Error; +use std::collections::BTreeMap; + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ApplicationRequirementsToml { + pub network: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApplicationNetworkRequirementsToml { + pub enabled: bool, + pub domains: BTreeMap, +} + +impl<'de> Deserialize<'de> for ApplicationNetworkRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Network { + enabled: Option, + #[serde(default)] + domains: BTreeMap, + } + + let raw = Network::deserialize(deserializer)?; + let mut domains = BTreeMap::new(); + for (domain, permission) in raw.domains { + let normalized = domain + .strip_suffix('.') + .unwrap_or(&domain) + .to_ascii_lowercase(); + if normalized.len() > 253 + || normalized.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || !label.starts_with(|c: char| c.is_ascii_alphanumeric()) + || !label.ends_with(|c: char| c.is_ascii_alphanumeric()) + || !label + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-') + }) + { + return Err(D::Error::custom( + "application.network.domains requires exact ASCII domain names without URLs, ports, or wildcards", + )); + } + if domains.insert(normalized, permission).is_some() { + return Err(D::Error::custom(format!( + "duplicate application.network domain after normalization: {domain:?}" + ))); + } + } + Ok(Self { + enabled: raw.enabled.unwrap_or(true), + domains, + }) + } +} + +#[cfg(test)] +#[path = "application_requirements_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/application_requirements_tests.rs b/codex-rs/config/src/application_requirements_tests.rs new file mode 100644 index 0000000000..e16f0fa69a --- /dev/null +++ b/codex-rs/config/src/application_requirements_tests.rs @@ -0,0 +1,251 @@ +//! Application requirements use managed precedence and deny unlisted destinations. + +use super::*; +use crate::ConfigRequirements; +use crate::ConfigRequirementsToml; +use crate::ConfigRequirementsWithSources; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::Sourced; +use crate::compose_requirements_for_hostname; +use pretty_assertions::assert_eq; + +const INSTALLATION: &str = r#" +[application.network.domains] +"shared.example.com" = "allow" +"installed.example.com" = "allow" +"blocked.example.com" = "deny" +"#; +const WORKSPACE: &str = r#" +[application.network.domains] +"shared.example.com" = "allow" +"workspace.example.com" = "allow" +"blocked.example.com" = "allow" +"#; +const MERGED: &str = r#" +[application.network.domains] +"shared.example.com" = "allow" +"installed.example.com" = "allow" +"workspace.example.com" = "allow" +"blocked.example.com" = "allow" +"#; + +fn parse(contents: &str) -> ConfigRequirementsToml { + toml::from_str(contents).expect("valid application requirements") +} + +fn cloud_source() -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: "workspace".to_string(), + name: "Workspace policy".to_string(), + } +} + +fn managed_source() -> RequirementSource { + RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements".to_string(), + } +} + +#[test] +fn application_network_defaults_to_enabled_and_denies_unlisted_domains() { + assert!(parse("[application]").is_empty()); + let requirements = parse("[application.network]"); + assert!(!requirements.is_empty()); + assert_eq!( + requirements.application, + Some(ApplicationRequirementsToml { + network: Some(ApplicationNetworkRequirementsToml { + enabled: true, + domains: BTreeMap::new(), + }), + }), + ); + assert_eq!( + parse("[application.network.domains]\n\"EXAMPLE.com.\" = \"deny\""), + parse("[application.network.domains]\n\"example.com\" = \"deny\""), + ); +} + +#[test] +fn application_network_is_preserved_from_each_managed_source() { + for source in [ + RequirementSource::SystemRequirementsToml { + file: crate::AbsolutePathBuf::try_from(std::env::temp_dir().join("requirements.toml")) + .expect("absolute requirements path"), + }, + managed_source(), + cloud_source(), + ] { + let requirements = compose_requirements_for_hostname( + [RequirementsLayerEntry::from_toml( + source.clone(), + INSTALLATION, + )], + /*hostname*/ None, + ) + .expect("compose application requirements") + .expect("application requirements are not empty"); + assert_eq!( + requirements, + ConfigRequirementsWithSources { + application: Some(Sourced::new( + parse(INSTALLATION).application.expect("application"), + source, + )), + ..Default::default() + }, + ); + let normalized: ConfigRequirements = + requirements.clone().try_into().expect("runtime policy"); + assert_eq!(normalized.application, requirements.application); + } +} + +#[test] +fn application_network_uses_regular_toml_precedence() { + let installed_wins = MERGED.replace( + "\"blocked.example.com\" = \"allow\"", + "\"blocked.example.com\" = \"deny\"", + ); + let disabled_installation = format!("[application.network]\nenabled = false\n{INSTALLATION}"); + for (low, high, expected) in [ + (INSTALLATION, WORKSPACE, MERGED), + (WORKSPACE, INSTALLATION, installed_wins.as_str()), + ( + INSTALLATION, + "[application.network]\nenabled = false", + disabled_installation.as_str(), + ), + ( + disabled_installation.as_str(), + "[application.network]\nenabled = true", + INSTALLATION, + ), + ( + "[application.network]\nenabled = false", + INSTALLATION, + disabled_installation.as_str(), + ), + (INSTALLATION, "[application.network]", INSTALLATION), + (INSTALLATION, "", INSTALLATION), + ("[application]", INSTALLATION, INSTALLATION), + ("[application.network]", WORKSPACE, WORKSPACE), + ( + "[application.network.domains]\n'example.com' = 'allow'", + "[application.network.domains]\n'EXAMPLE.COM.' = 'deny'", + "[application.network.domains]\n'example.com' = 'deny'", + ), + ( + "[application.network.domains]\n'EXAMPLE.COM.' = 'deny'", + "[application.network.domains]\n'example.com' = 'allow'", + "[application.network.domains]\n'example.com' = 'allow'", + ), + ] { + let composed = compose_requirements_for_hostname( + [ + RequirementsLayerEntry::from_toml(managed_source(), low), + RequirementsLayerEntry::from_toml(cloud_source(), high), + ], + /*hostname*/ None, + ) + .expect("compose application requirements") + .expect("configured requirements"); + assert_eq!( + composed.into_toml(), + parse(expected), + "low: {low}, high: {high}" + ); + } +} + +#[test] +fn legacy_application_merge_uses_whole_table_precedence() { + for (high, low) in [ + (INSTALLATION, WORKSPACE), + (WORKSPACE, INSTALLATION), + ("[application.network]\nenabled = false", INSTALLATION), + ("[application]", INSTALLATION), + ] { + let mut composed = ConfigRequirementsWithSources::default(); + composed.merge_unset_fields(managed_source(), parse(high)); + composed.merge_unset_fields(cloud_source(), parse(low)); + assert_eq!( + composed, + ConfigRequirementsWithSources { + application: Some(Sourced::new( + parse(high).application.expect("application"), + managed_source(), + )), + ..Default::default() + }, + ); + } + let mut composed = ConfigRequirementsWithSources::default(); + composed.merge_unset_fields(managed_source(), parse("")); + composed.merge_unset_fields(cloud_source(), parse(INSTALLATION)); + assert_eq!(composed.into_toml(), parse(INSTALLATION)); +} + +#[test] +fn invalid_application_policy_is_rejected_before_layer_overrides() { + for invalid in [ + "[application.network]\nenabled = 'true'", + "[application.network]\nallowed_domains = ['example.com']", + "[application.network.domains]\n'example.com' = 'prompt'", + "[application.network.domains]\n'*.example.com' = 'allow'", + "[application.network.domains]\n'https://example.com' = 'allow'", + "[application.network.domains]\n'example.com:443' = 'allow'", + "[application.network.domains]\n'example..com' = 'allow'", + "[application.network.domains]\n' example.com' = 'allow'", + "[application.network.domains]\n'EXAMPLE.com' = 'allow'\n'example.com.' = 'deny'", + ] { + assert!( + toml::from_str::(invalid).is_err(), + "{invalid}" + ); + assert!( + compose_requirements_for_hostname( + [ + RequirementsLayerEntry::from_toml(cloud_source(), invalid), + RequirementsLayerEntry::from_toml(managed_source(), INSTALLATION), + ], + /*hostname*/ None, + ) + .is_err(), + "{invalid}" + ); + } +} + +#[test] +fn cloud_bundle_application_network_uses_managed_precedence() { + let base_dir = + crate::AbsolutePathBuf::try_from(std::env::temp_dir()).expect("absolute base directory"); + let bundle = crate::CloudConfigBundle { + requirements_toml: crate::CloudRequirementsTomlBundle { + enterprise_managed: vec![crate::CloudRequirementsFragment { + id: "workspace".to_string(), + name: "Workspace".to_string(), + contents: WORKSPACE.to_string(), + }], + }, + ..Default::default() + }; + let mut layers = crate::CloudConfigBundleLayers::from_bundle(bundle, &base_dir) + .expect("load cloud bundle") + .enterprise_managed_requirements; + layers.push(RequirementsLayerEntry::from_toml( + managed_source(), + INSTALLATION, + )); + let composed = compose_requirements_for_hostname(layers, /*hostname*/ None) + .expect("compose cloud and installation requirements") + .expect("requirements"); + let expected = MERGED.replace( + "\"blocked.example.com\" = \"allow\"", + "\"blocked.example.com\" = \"deny\"", + ); + assert_eq!(composed.into_toml(), parse(&expected)); +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 6262c8dc15..724a7c4461 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1,3 +1,4 @@ +use crate::ApplicationRequirementsToml; use codex_features::FeatureToml; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ForcedLoginMethod; @@ -189,6 +190,7 @@ pub struct ConfigRequirements { pub enforce_residency: ConstrainedWithSource>, /// Managed network constraints derived from requirements. pub network: Option>, + pub application: Option>, /// Managed filesystem constraints derived from requirements. pub filesystem: Option>, /// Managed instructions included independently of ordinary developer instructions. @@ -247,6 +249,7 @@ impl Default for ConfigRequirements { /*source*/ None, ), network: None, + application: None, filesystem: None, additional_developer_instructions: None, guardian_policy_config_source: None, @@ -1015,6 +1018,7 @@ pub struct ConfigRequirementsToml { pub enforce_residency: Option, #[serde(rename = "experimental_network")] pub network: Option, + pub application: Option, pub permissions: Option, pub auto_review: Option, pub models: Option, @@ -1117,6 +1121,7 @@ pub struct ConfigRequirementsWithSources { pub rules: Option>, pub enforce_residency: Option>, pub network: Option>, + pub application: Option>, pub permissions: Option>, pub auto_review: Option>, pub models: Option>, @@ -1177,6 +1182,7 @@ impl ConfigRequirementsWithSources { rules: _, enforce_residency: _, network: _, + application: _, permissions: _, auto_review: _, models: _, @@ -1229,6 +1235,7 @@ impl ConfigRequirementsWithSources { rules, enforce_residency, network, + application, permissions, models, additional_developer_instructions, @@ -1312,6 +1319,7 @@ impl ConfigRequirementsWithSources { rules, enforce_residency, network, + application, permissions, auto_review, models, @@ -1354,6 +1362,7 @@ impl ConfigRequirementsWithSources { rules: rules.map(|sourced| sourced.value), enforce_residency: enforce_residency.map(|sourced| sourced.value), network: network.map(|sourced| sourced.value), + application: application.map(|sourced| sourced.value), permissions: permissions.map(|sourced| sourced.value), auto_review: auto_review.map(|sourced| sourced.value), models: models.map(|sourced| sourced.value), @@ -1492,6 +1501,10 @@ impl ConfigRequirementsToml { && self.rules.is_none() && self.enforce_residency.is_none() && self.network.is_none() + && self + .application + .as_ref() + .is_none_or(|application| application.network.is_none()) && self.permissions.is_none() && self.auto_review.as_ref().is_none_or(|auto_review| { auto_review.ignore_rules.as_ref().is_none_or(Vec::is_empty) @@ -1678,6 +1691,7 @@ impl TryFrom for ConfigRequirements { rules, enforce_residency, network, + application, permissions, auto_review, models: _, @@ -2037,6 +2051,7 @@ impl TryFrom for ConfigRequirements { exec_policy, enforce_residency, network, + application, filesystem, additional_developer_instructions, guardian_policy_config_source, @@ -2215,6 +2230,7 @@ mod tests { rules, enforce_residency, network, + application, permissions, auto_review, models, @@ -2275,6 +2291,7 @@ mod tests { enforce_residency: enforce_residency .map(|value| Sourced::new(value, RequirementSource::Unknown)), network: network.map(|value| Sourced::new(value, RequirementSource::Unknown)), + application: application.map(|value| Sourced::new(value, RequirementSource::Unknown)), permissions: permissions.map(|value| Sourced::new(value, RequirementSource::Unknown)), auto_review: auto_review.map(|value| Sourced::new(value, RequirementSource::Unknown)), models: models.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -2777,6 +2794,7 @@ mod tests { rules: None, enforce_residency: Some(enforce_residency), network: None, + application: None, permissions: None, auto_review: Some(auto_review.clone()), models: Some(models.clone()), @@ -2861,6 +2879,7 @@ mod tests { rules: None, enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), network: None, + application: None, permissions: None, auto_review: Some(Sourced::new(auto_review, source.clone())), models: Some(Sourced::new(models, source.clone())), @@ -2916,6 +2935,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + application: None, permissions: None, models: None, guardian_policy_config: None, @@ -2975,6 +2995,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + application: None, permissions: None, models: None, guardian_policy_config: None, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 5cbf501fe0..6f5c4d8715 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,3 +1,4 @@ +mod application_requirements; mod auth_policy; mod browser_computer_use_requirements; mod browser_use; @@ -39,6 +40,8 @@ pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use application_requirements::ApplicationNetworkRequirementsToml; +pub use application_requirements::ApplicationRequirementsToml; pub use auth_policy::ManagedAuthPolicy; pub use browser_computer_use_requirements::AllowDenyRequirementToml; pub use browser_computer_use_requirements::BrowserUseAccessApprovalLifetimeToml; diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs index cc1b08d668..14a5c5a5af 100644 --- a/codex-rs/config/src/merge.rs +++ b/codex-rs/config/src/merge.rs @@ -98,7 +98,7 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m normalize_key_aliases(path, base_table); let mut overlay_table = overlay_table.clone(); normalize_key_aliases(path, &mut overlay_table); - if is_permission_network_domains_path(path) { + if is_network_domains_path(path) { normalize_network_domain_keys(base_table); normalize_network_domain_keys(&mut overlay_table); } @@ -187,11 +187,15 @@ pub fn shell_environment_filter_entry<'a>( .find(|(candidate, _)| candidate.to_lowercase() == pattern) } -fn is_permission_network_domains_path(path: &[String]) -> bool { +fn is_network_domains_path(path: &[String]) -> bool { matches!( path, [permissions, _, network, domains] if permissions == "permissions" && network == "network" && domains == "domains" + ) || matches!( + path, + [application, network, domains] + if application == "application" && network == "network" && domains == "domains" ) } diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 09e3f13cd5..5e6d381b97 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -245,6 +245,7 @@ fn populate_merged_regular_fields_with_sources( rules: _, enforce_residency, network, + application, permissions, auto_review, models, @@ -296,6 +297,7 @@ fn populate_merged_regular_fields_with_sources( set_sourced!(apps, &["apps"]); set_sourced!(enforce_residency, &["enforce_residency"]); set_sourced!(network, &["experimental_network"]); + set_sourced!(application, &["application"]); set_sourced!(permissions, &["permissions"]); set_sourced!(models, &["models"]); set_sourced!( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6d0d50640c..b1bb7ecddf 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9971,6 +9971,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), + application: None, allow_managed_hooks_only: None, allow_appshots: None, allow_remote_control: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cd339cd813..748ef9b2a0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3220,6 +3220,7 @@ impl Config { exec_policy: _, enforce_residency, network: network_requirements, + application: _, filesystem: filesystem_requirements, additional_developer_instructions: _, guardian_policy_config_source: _, diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 800bfd6084..9628367a52 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -169,7 +169,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Result<()> { let mut app = make_test_app().await; let mut ids = Vec::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 ca0b17b3a6..3b31bc5537 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -3358,8 +3358,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { }; threads.push(discovered); } - app.handle_event(&mut tui, &mut app_server, completion) - .await?; + Box::pin(app.handle_event(&mut tui, &mut app_server, completion)).await?; assert_eq!( app.agent_navigation .ordered_threads() diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index bd2eac75b9..365200aab6 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -955,6 +955,7 @@ interrupt_message = false }; let requirements_toml = ConfigRequirementsToml { + application: None, allowed_login_methods: None, allowed_chatgpt_workspaces: None, cli_auth_credentials_store: None,