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 6802d16353..9e06959515 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 @@ -9064,6 +9064,16 @@ } ] }, + "inAppBrowser": { + "anyOf": [ + { + "$ref": "#/definitions/v2/InAppBrowserRequirements" + }, + { + "type": "null" + } + ] + }, "logDir": { "type": [ "string", @@ -12419,6 +12429,17 @@ } ] }, + "InAppBrowserRequirements": { + "properties": { + "allowExternalBrowserSettingsImport": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "InputModality": { "description": "Canonical user-input modality tags advertised by a model.", "oneOf": [ 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 9a40484e12..f40e3177b5 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 @@ -5122,6 +5122,16 @@ } ] }, + "inAppBrowser": { + "anyOf": [ + { + "$ref": "#/definitions/InAppBrowserRequirements" + }, + { + "type": "null" + } + ] + }, "logDir": { "type": [ "string", @@ -8588,6 +8598,17 @@ } ] }, + "InAppBrowserRequirements": { + "properties": { + "allowExternalBrowserSettingsImport": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "InitializeCapabilities": { "description": "Client-declared capabilities negotiated during initialize.", "properties": { 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 2b8525b8d4..6f32d3ce34 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -271,6 +271,16 @@ } ] }, + "inAppBrowser": { + "anyOf": [ + { + "$ref": "#/definitions/InAppBrowserRequirements" + }, + { + "type": "null" + } + ] + }, "logDir": { "type": [ "string", @@ -471,6 +481,17 @@ }, "type": "object" }, + "InAppBrowserRequirements": { + "properties": { + "allowExternalBrowserSettingsImport": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { 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 1abd9d55cd..4c8be3a061 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 b5554016a2..7ecfeadaca 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/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 60dc414839..3e4e73209c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -9,9 +9,10 @@ import type { BrowserUseRequirements } from "./BrowserUseRequirements"; import type { CliAuthCredentialsStoreMode } from "./CliAuthCredentialsStoreMode"; import type { ComputerUseRequirements } from "./ComputerUseRequirements"; import type { FeedbackRequirements } from "./FeedbackRequirements"; +import type { InAppBrowserRequirements } from "./InAppBrowserRequirements"; import type { ModelsRequirements } from "./ModelsRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; +export type ConfigRequirements = {cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, inAppBrowser: InAppBrowserRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/InAppBrowserRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/InAppBrowserRequirements.ts new file mode 100644 index 0000000000..5bafbf0c0b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/InAppBrowserRequirements.ts @@ -0,0 +1,5 @@ +// 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. + +export type InAppBrowserRequirements = { allowExternalBrowserSettingsImport: boolean | 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 4a8984a804..d46190b14f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -208,6 +208,7 @@ export type { HookTrustStatus } from "./HookTrustStatus"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; +export type { InAppBrowserRequirements } from "./InAppBrowserRequirements"; export type { InstalledApp } from "./InstalledApp"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; 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 51a5f51dbe..f87c2c55aa 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -399,6 +399,7 @@ pub struct ConfigRequirements { pub allow_remote_control: Option, pub computer_use: Option, pub browser_use: Option, + pub in_app_browser: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] pub hooks: Option, @@ -474,6 +475,13 @@ pub struct BrowserUseRequirements { pub disable_auto_review: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct InAppBrowserRequirements { + pub allow_external_browser_settings_import: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 a4d867cf9e..6ac1814fd9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2060,6 +2060,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allow_remote_control: None, computer_use: None, browser_use: None, + in_app_browser: None, feature_requirements: None, hooks: None, enforce_residency: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 483924a572..4f5b471d04 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -288,7 +288,7 @@ Example with notification opt-out: - `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Successful session entries include the original imported title when one was available. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. Writes that overlap a managed requirement are rejected with `configRequirementReadonly`. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults do not reload existing threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`cliAuthCredentialsStore`, `chatgptBaseUrl`, `sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including command handlers with optional `additionalContextLimit` and `mcp_tool` handlers with `server`, `tool`, `input`, `timeoutSec`, and `statusMessage`), `enforceResidency`, managed automatic review (`autoReview.requiredOnModels` and `autoReview.ignoreRules`), model defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`cliAuthCredentialsStore`, `chatgptBaseUrl`, `sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), interactive browser import policy (`inAppBrowser.allowExternalBrowserSettingsImport`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including command handlers with optional `additionalContextLimit` and `mcp_tool` handlers with `server`, `tool`, `input`, `timeoutSec`, and `statusMessage`), `enforceResidency`, managed automatic review (`autoReview.requiredOnModels` and `autoReview.ignoreRules`), model defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread 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 ed59d98457..053edf2912 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -25,6 +25,7 @@ use codex_app_server_protocol::ConfiguredHookMatcherGroup; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::FeedbackRequirements; +use codex_app_server_protocol::InAppBrowserRequirements; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ManagedHooksRequirements; use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; @@ -426,6 +427,12 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR browser_use: requirements .browser_use .map(map_browser_use_requirements_to_api), + in_app_browser: requirements.in_app_browser.map(|in_app_browser| { + InAppBrowserRequirements { + allow_external_browser_settings_import: in_app_browser + .allow_external_browser_settings_import, + } + }), feature_requirements: requirements .feature_requirements .map(|requirements| requirements.entries), diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_in_app_browser.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_in_app_browser.rs new file mode 100644 index 0000000000..e1d49d7db6 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_in_app_browser.rs @@ -0,0 +1,143 @@ +use anyhow::Result; +use anyhow::ensure; +use app_test_support::TestAppServer; +use codex_app_server_protocol::BrowserUseRequirements; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ConfigRequirementsReadResponse; +use codex_app_server_protocol::InAppBrowserRequirements; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60); +const ALLOW: &str = "[in_app_browser]\nallow_external_browser_settings_import = true"; +const DENY: &str = "[in_app_browser]\nallow_external_browser_settings_import = false"; + +async fn start_stable_server(codex_home: &TempDir) -> Result { + let mut server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + let initialized = timeout( + READ_TIMEOUT, + server.initialize_with_capabilities( + ClientInfo { + name: "browser_import_policy_test".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + /*capabilities*/ None, + ), + ) + .await??; + ensure!(matches!(initialized, JSONRPCMessage::Response(_))); + Ok(server) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_preserves_import_policy() -> Result<()> { + for (contents, expected_table) in [ + ("", None), + ("[in_app_browser]", None), + ("allow_remote_control = true", None), + ("allow_remote_control = true\n[in_app_browser]", Some(None)), + (ALLOW, Some(Some(true))), + (DENY, Some(Some(false))), + ] { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("requirements.toml"), contents)?; + // An unrecognized ordinary user setting cannot override managed policy. + std::fs::write(codex_home.path().join("config.toml"), ALLOW)?; + let mut server = start_stable_server(&codex_home).await?; + let request_id = server.send_config_requirements_read_request().await?; + let wire: Value = timeout(READ_TIMEOUT, server.read_response(request_id)).await??; + let expected_wire = expected_table + .map(|value| json!({ "allowExternalBrowserSettingsImport": value })) + .unwrap_or(Value::Null); + if let Some(requirements) = wire["requirements"].as_object() { + assert_eq!(requirements.get("inAppBrowser"), Some(&expected_wire)); + } else { + assert_eq!(wire, json!({ "requirements": null })); + } + let response: ConfigRequirementsReadResponse = serde_json::from_value(wire)?; + let actual = response + .requirements + .and_then(|requirements| requirements.in_app_browser); + assert_eq!( + actual, + expected_table.map(|value| InAppBrowserRequirements { + allow_external_browser_settings_import: value, + }), + ); + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn import_policy_is_separate_from_browser_feature_and_agent_policy() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("requirements.toml"), + format!( + "[features]\nin_app_browser = true\nbrowser_use = false\n\ + [browser_use]\ndisable_auto_review = true\n{DENY}" + ), + )?; + std::fs::write(codex_home.path().join("config.toml"), ALLOW)?; + let mut server = start_stable_server(&codex_home).await?; + let request_id = server.send_config_requirements_read_request().await?; + let response: ConfigRequirementsReadResponse = + timeout(READ_TIMEOUT, server.read_response(request_id)).await??; + let requirements = response.requirements.expect("managed requirements"); + assert_eq!( + ( + requirements.in_app_browser, + requirements.browser_use, + requirements.feature_requirements, + ), + ( + Some(InAppBrowserRequirements { + allow_external_browser_settings_import: Some(false), + }), + Some(BrowserUseRequirements { + disable_auto_review: Some(true), + }), + Some(BTreeMap::from([ + ("browser_use".to_string(), false), + ("in_app_browser".to_string(), true), + ])), + ), + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_rejects_invalid_import_policy() -> Result<()> { + let codex_home = TempDir::new()?; + let mut server = start_stable_server(&codex_home).await?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "[in_app_browser]\nallow_external_browser_settings_import = \"false\"", + )?; + 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??; + ensure!( + error + .error + .message + .contains("allow_external_browser_settings_import"), + "{}", + error.error.message, + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index f34a78e95f..2d058cfaa8 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -12,6 +12,7 @@ mod collaboration_mode_list; #[cfg(unix)] mod command_exec; mod compaction; +mod config_requirements_in_app_browser; mod config_rpc; mod connection_handling_websocket; #[cfg(unix)] diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 0c9024841b..bf10a161d8 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -21,6 +21,7 @@ use wildmatch::WildMatchPattern; use super::requirements_exec_policy::RequirementsExecPolicyToml; use crate::Constrained; use crate::ConstraintError; +use crate::InAppBrowserRequirementsToml; use crate::ManagedAuthPolicy; use crate::ManagedHooksRequirementsToml; use crate::McpServerRequirement; @@ -942,6 +943,7 @@ pub struct ConfigRequirementsToml { pub allow_remote_control: Option, pub computer_use: Option, pub browser_use: Option, + pub in_app_browser: Option, pub windows: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, @@ -1043,6 +1045,7 @@ pub struct ConfigRequirementsWithSources { pub allow_remote_control: Option>, pub computer_use: Option>, pub browser_use: Option>, + pub in_app_browser: Option>, pub windows: Option>, pub feature_requirements: Option>, pub hooks: Option>, @@ -1100,6 +1103,7 @@ impl ConfigRequirementsWithSources { allow_remote_control: _, computer_use: _, browser_use: _, + in_app_browser: _, windows: _, feature_requirements: _, hooks: _, @@ -1150,6 +1154,7 @@ impl ConfigRequirementsWithSources { allow_remote_control, computer_use, browser_use, + in_app_browser, windows, feature_requirements, hooks, @@ -1229,6 +1234,7 @@ impl ConfigRequirementsWithSources { allow_remote_control, computer_use, browser_use, + in_app_browser, windows, feature_requirements, hooks, @@ -1267,6 +1273,7 @@ impl ConfigRequirementsWithSources { allow_remote_control: allow_remote_control.map(|sourced| sourced.value), computer_use: computer_use.map(|sourced| sourced.value), browser_use: browser_use.map(|sourced| sourced.value), + in_app_browser: in_app_browser.map(|sourced| sourced.value), windows: windows.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), @@ -1380,6 +1387,10 @@ impl ConfigRequirementsToml { .browser_use .as_ref() .is_none_or(BrowserUseRequirementsToml::is_empty) + && self + .in_app_browser + .as_ref() + .is_none_or(|requirements| requirements == &InAppBrowserRequirementsToml::default()) && self .windows .as_ref() @@ -1581,6 +1592,7 @@ impl TryFrom for ConfigRequirements { allow_remote_control, computer_use, browser_use: _, + in_app_browser: _, windows, feature_requirements, hooks, @@ -2108,6 +2120,7 @@ mod tests { allow_remote_control, computer_use, browser_use, + in_app_browser, windows, feature_requirements, hooks, @@ -2161,6 +2174,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), browser_use: browser_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), + in_app_browser: in_app_browser + .map(|value| Sourced::new(value, RequirementSource::Unknown)), windows: windows.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -2457,6 +2472,7 @@ mod tests { allow_remote_control: Some(false), computer_use: Some(computer_use.clone()), browser_use: None, + in_app_browser: None, windows: Some(windows.clone()), feature_requirements: Some(feature_requirements.clone()), hooks: None, @@ -2532,6 +2548,7 @@ mod tests { )), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), browser_use: None, + in_app_browser: None, windows: Some(Sourced::new(windows, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, @@ -2585,6 +2602,7 @@ mod tests { allow_remote_control: None, computer_use: None, browser_use: None, + in_app_browser: None, windows: None, feature_requirements: None, hooks: None, @@ -2643,6 +2661,7 @@ mod tests { allow_remote_control: None, computer_use: None, browser_use: None, + in_app_browser: None, windows: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/config/src/in_app_browser_requirements.rs b/codex-rs/config/src/in_app_browser_requirements.rs new file mode 100644 index 0000000000..54442f88f6 --- /dev/null +++ b/codex-rs/config/src/in_app_browser_requirements.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; + +/// Managed requirements for the interactive in-app browser, not agent Browser Use. +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct InAppBrowserRequirementsToml { + /// Whether external browser settings may be imported. Only `Some(false)` + /// denies import; omission is effectively `true`. Keep omission unset while + /// composing managed layers so it cannot replace an explicit requirement. + pub allow_external_browser_settings_import: Option, +} + +#[cfg(test)] +#[path = "in_app_browser_requirements_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/in_app_browser_requirements_tests.rs b/codex-rs/config/src/in_app_browser_requirements_tests.rs new file mode 100644 index 0000000000..9e9fbf2b46 --- /dev/null +++ b/codex-rs/config/src/in_app_browser_requirements_tests.rs @@ -0,0 +1,144 @@ +use super::InAppBrowserRequirementsToml; +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 EMPTY: &str = "[in_app_browser]"; +const ALLOW: &str = "[in_app_browser]\nallow_external_browser_settings_import = true"; +const DENY: &str = "[in_app_browser]\nallow_external_browser_settings_import = false"; + +fn parse(contents: &str) -> ConfigRequirementsToml { + toml::from_str(contents).expect("parse requirements") +} + +fn source(name: &str) -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: name.to_string(), + name: name.to_string(), + } +} + +#[test] +fn import_requirement_preserves_unset_and_explicit_values() { + for (contents, expected_table) in [ + ("", None), + (EMPTY, Some(None)), + (ALLOW, Some(Some(true))), + (DENY, Some(Some(false))), + ] { + let requirements = parse(contents); + assert_eq!( + requirements, + ConfigRequirementsToml { + in_app_browser: expected_table.map(|value| InAppBrowserRequirementsToml { + allow_external_browser_settings_import: value, + }), + ..Default::default() + }, + ); + let configured = requirements + .in_app_browser + .as_ref() + .and_then(|policy| policy.allow_external_browser_settings_import); + assert_eq!(requirements.is_empty(), configured.is_none()); + } +} + +#[test] +fn import_requirement_uses_normal_managed_toml_precedence() { + for (low, high, expected) in [ + ("", EMPTY, None), + (DENY, "", Some(DENY)), + (DENY, EMPTY, Some(DENY)), + (DENY, ALLOW, Some(ALLOW)), + (ALLOW, DENY, Some(DENY)), + ] { + let composed = compose_requirements_for_hostname( + [ + RequirementsLayerEntry::from_toml(source("low"), low), + RequirementsLayerEntry::from_toml(source("high"), high), + ], + /*hostname*/ None, + ) + .expect("compose managed import policy") + .map(ConfigRequirementsWithSources::into_toml); + assert_eq!(composed, expected.map(parse)); + } + + let composed = compose_requirements_for_hostname( + [RequirementsLayerEntry::from_toml(source("managed"), DENY)], + /*hostname*/ None, + ) + .expect("compose managed denial"); + assert_eq!( + composed, + Some(ConfigRequirementsWithSources { + in_app_browser: Some(Sourced::new( + InAppBrowserRequirementsToml { + allow_external_browser_settings_import: Some(false), + }, + source("managed"), + )), + ..Default::default() + }), + ); +} + +#[test] +fn legacy_merge_uses_whole_table_precedence() { + for (high, low, expected, expected_source) in [ + (EMPTY, DENY, EMPTY, "high"), + ("", ALLOW, ALLOW, "low"), + (DENY, ALLOW, DENY, "high"), + (ALLOW, DENY, ALLOW, "high"), + ] { + let mut composed = ConfigRequirementsWithSources::default(); + composed.merge_unset_fields(source("high"), parse(high)); + composed.merge_unset_fields(source("low"), parse(low)); + assert_eq!( + composed, + ConfigRequirementsWithSources { + in_app_browser: Some(Sourced::new( + parse(expected).in_app_browser.expect("requirements table"), + source(expected_source), + )), + ..Default::default() + }, + ); + assert_eq!(composed.into_toml(), parse(expected)); + } +} + +#[test] +fn invalid_import_requirement_is_rejected_even_when_overridden() { + for invalid in ["\"false\"", "1", "[]", "{}"] { + let contents = + format!("[in_app_browser]\nallow_external_browser_settings_import = {invalid}"); + let error = toml::from_str::(&contents) + .expect_err("import requirement must be a boolean"); + assert!( + error + .to_string() + .contains("allow_external_browser_settings_import"), + "{error}", + ); + let error = compose_requirements_for_hostname( + [ + RequirementsLayerEntry::from_toml(source("invalid"), contents), + RequirementsLayerEntry::from_toml(source("higher"), ALLOW), + ], + /*hostname*/ None, + ) + .expect_err("invalid managed layers must not be silently ignored"); + assert!( + error + .to_string() + .contains("allow_external_browser_settings_import"), + "{error}", + ); + } +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index d8ad0f49ec..602ad17a20 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -9,6 +9,7 @@ mod diagnostics; mod fingerprint; mod hook_config; mod host_name; +mod in_app_browser_requirements; mod key_aliases; pub mod loader; mod marketplace_edit; @@ -119,6 +120,7 @@ pub use hook_config::HooksToml; pub use hook_config::ManagedHooksRequirementsToml; pub use hook_config::MatcherGroup; pub use host_name::host_name; +pub use in_app_browser_requirements::InAppBrowserRequirementsToml; pub use marketplace_edit::MarketplaceConfigUpdate; pub use marketplace_edit::RemoveMarketplaceConfigOutcome; pub use marketplace_edit::record_user_marketplace; diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 6f3979cf61..4c9847cf4c 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::ConfigRequirementsToml; use codex_file_system::CopyOptions; use codex_file_system::CreateDirectoryOptions; use codex_file_system::ExecutorFileSystemFuture; @@ -16,6 +17,44 @@ use tempfile::tempdir; pub(super) struct TestFileSystem; +#[tokio::test] +async fn managed_browser_import_denial_survives_user_and_session_config() { + let tmp = tempdir().expect("tempdir"); + let allow = "[in_app_browser]\nallow_external_browser_settings_import = true"; + let deny = "[in_app_browser]\nallow_external_browser_settings_import = false"; + let requirements_path = tmp.path().join("requirements.toml"); + std::fs::write(&requirements_path, allow).expect("write system requirements"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), allow).expect("write user config"); + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + loader_overrides.system_requirements_path = Some(requirements_path); + + let stack = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[( + "in_app_browser.allow_external_browser_settings_import".to_string(), + TomlValue::Boolean(true), + )], + ConfigLoadOptions { + loader_overrides, + strict_config: false, + cloud_config_bundle: + crate::test_support::CloudConfigBundleFixture::enterprise_requirement(deny) + .add_enterprise_requirement("[in_app_browser]") + .into_loader(), + }, + &crate::NoopThreadConfigLoader, + ) + .await + .expect("load managed browser import requirement"); + + assert_eq!( + stack.requirements_toml(), + &toml::from_str::(deny).expect("expected requirement"), + ); +} + impl ExecutorFileSystem for TestFileSystem { fn canonicalize<'a>( &'a self, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 665430e08c..9c91fd9ca8 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -233,6 +233,7 @@ fn populate_merged_regular_fields_with_sources( allow_remote_control, computer_use, browser_use, + in_app_browser, windows, feature_requirements, hooks: _, @@ -280,6 +281,7 @@ fn populate_merged_regular_fields_with_sources( set_sourced!(auto_review, &["auto_review"]); set_sourced!(computer_use, &["computer_use"]); set_sourced!(browser_use, &["browser_use"]); + set_sourced!(in_app_browser, &["in_app_browser"]); set_sourced!(windows, &["windows"]); set_sourced!(feature_requirements, &["features", "feature_requirements"]); set_sourced!(mcp_servers, &["mcp_servers"]); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 22e5724704..9c37647560 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9844,6 +9844,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allow_remote_control: None, computer_use: None, browser_use: None, + in_app_browser: None, windows: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 8bc95c5622..00369b7ecb 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -955,6 +955,7 @@ interrupt_message = false allow_remote_control: Some(false), computer_use: None, browser_use: None, + in_app_browser: None, windows: Some(WindowsRequirementsToml { allowed_sandbox_implementations: None, sandbox_private_desktop: Some(false),