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 c672b930b9..0bde0ace0e 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 @@ -8098,6 +8098,12 @@ "null" ] }, + "allowWebmcp": { + "type": [ + "boolean", + "null" + ] + }, "defaultOriginPolicy": { "anyOf": [ { 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 e018ba11cc..7261f8be2f 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 @@ -1582,6 +1582,12 @@ "null" ] }, + "allowWebmcp": { + "type": [ + "boolean", + "null" + ] + }, "defaultOriginPolicy": { "anyOf": [ { 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 fb5cc7029b..26a4862f52 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -215,6 +215,12 @@ "null" ] }, + "allowWebmcp": { + "type": [ + "boolean", + "null" + ] + }, "defaultOriginPolicy": { "anyOf": [ { 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 8c723e3381..e4f160fbc9 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 bd13ac46e8..367a3c970b 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/BrowserUseRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/BrowserUseRequirements.ts index 894caa95ce..1430f756e5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/BrowserUseRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/BrowserUseRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { BrowserUseOriginPolicy } from "./BrowserUseOriginPolicy"; -export type BrowserUseRequirements = { allowHistoryAccess: boolean | null, disableAutoReview: boolean | null, allowGlobalPersistentApproval: boolean | null, defaultOriginPolicy: BrowserUseOriginPolicy | null, origins: { [key in string]?: BrowserUseOriginPolicy } | null, }; +export type BrowserUseRequirements = { allowWebmcp: boolean | null, allowHistoryAccess: boolean | null, disableAutoReview: boolean | null, allowGlobalPersistentApproval: boolean | null, defaultOriginPolicy: BrowserUseOriginPolicy | null, origins: { [key in string]?: BrowserUseOriginPolicy } | null, }; 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 ad8e1bcd8d..5e5f58d579 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -505,6 +505,7 @@ pub struct ComputerUseRequirements { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct BrowserUseRequirements { + pub allow_webmcp: Option, pub allow_history_access: Option, pub disable_auto_review: Option, pub allow_global_persistent_approval: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e89499bf31..7bd9b7a026 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -314,7 +314,8 @@ 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`), requirements-only developer instructions (`additionalDeveloperInstructions`, supplied independently of ordinary developer instructions), 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), the Browser/Computer Use umbrella policy (`allowBrowserAndComputerUse`), computer use policy (`computerUse`, including persistent approval, default application access, and per-platform application rules), Browser Use policy (`browserUse`, including history access, origin rules, auto-review, and approval controls), 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`. +- `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`), requirements-only developer instructions (`additionalDeveloperInstructions`, supplied independently of ordinary developer instructions), 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), the Browser/Computer Use umbrella policy (`allowBrowserAndComputerUse`), computer use policy (`computerUse`, including persistent approval, default application access, and per-platform application rules), Browser Use policy (`browserUse`, including WebMCP enablement, history access, origin rules, auto-review, and approval controls), 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`. + - Managed `[browser_use].allow_webmcp` is returned as `browserUse.allowWebmcp`, preserving `true`, `false`, and omission as `null`. Desktop clients enable WebMCP when their WebMCP Statsig gate is on **or** this field is `true`. A `false` or omitted policy does not disable a gate-enabled feature. When policy is omitted, enterprise and consumer defaults come from Statsig. This is a requirements-only field; ordinary `config.toml` settings cannot enable it through this API. `mcpServer/resource/read` and `mcpServer/tool/call` preserve MCP protocol errors with their original `code`, `message`, and `data`, including authentication 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 9dab485802..df9c503101 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -561,6 +561,7 @@ fn map_browser_use_requirements_to_api( browser_use: codex_config::BrowserUseRequirementsToml, ) -> BrowserUseRequirements { BrowserUseRequirements { + allow_webmcp: browser_use.allow_webmcp, allow_history_access: browser_use.allow_history_access, disable_auto_review: browser_use.disable_auto_review, allow_global_persistent_approval: browser_use.allow_global_persistent_approval, @@ -940,6 +941,7 @@ mod tests { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { allow_browser_and_computer_use: Some(false), browser_use: Some(BrowserUseRequirementsToml { + allow_webmcp: Some(true), allow_history_access: Some(false), disable_auto_review: Some(true), allow_global_persistent_approval: Some(false), @@ -997,6 +999,7 @@ mod tests { assert_eq!( mapped.browser_use, Some(BrowserUseRequirements { + allow_webmcp: Some(true), allow_history_access: Some(false), disable_auto_review: Some(true), allow_global_persistent_approval: Some(false), diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_browser_use_tests.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_browser_use_tests.rs new file mode 100644 index 0000000000..99f4e8fb56 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_browser_use_tests.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use app_test_support::TestAppServer; +use codex_app_server_protocol::ClientInfo; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_preserves_webmcp_policy() -> Result<()> { + for (contents, expected_browser_use) in [ + ("", None), + ("[browser_use]", None), + ( + "[browser_use]\nallow_history_access = true", + Some((Some(true), None)), + ), + ( + "[browser_use]\nallow_webmcp = true", + Some((None, Some(true))), + ), + ( + "[browser_use]\nallow_webmcp = false", + Some((None, Some(false))), + ), + ] { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("requirements.toml"), contents)?; + std::fs::write( + codex_home.path().join("config.toml"), + "[browser_use]\nallow_webmcp = true", + )?; + let mut server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + let read_timeout = Duration::from_secs(/*secs*/ 60); + timeout( + read_timeout, + server.initialize_with_capabilities( + ClientInfo { + name: "webmcp_policy_test".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + /*capabilities*/ None, + ), + ) + .await??; + let request_id = server.send_config_requirements_read_request().await?; + let wire: Value = timeout(read_timeout, server.read_response(request_id)).await??; + if let Some((allow_history_access, allow_webmcp)) = expected_browser_use { + assert_eq!( + wire["requirements"]["browserUse"], + json!({ + "allowWebmcp": allow_webmcp, + "allowHistoryAccess": allow_history_access, + "disableAutoReview": null, + "allowGlobalPersistentApproval": null, + "defaultOriginPolicy": null, + "origins": null, + }), + "{contents}", + ); + } else { + assert_eq!(wire, json!({ "requirements": null }), "{contents}"); + } + } + Ok(()) +} 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 index 6cdc14d455..007976c9bd 100644 --- 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 @@ -106,6 +106,7 @@ async fn import_policy_is_separate_from_browser_feature_and_agent_policy() -> Re allow_external_browser_settings_import: Some(false), }), Some(BrowserUseRequirements { + allow_webmcp: None, allow_history_access: None, disable_auto_review: Some(true), allow_global_persistent_approval: None, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 68c77cc665..18d480ff7d 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -297,6 +297,7 @@ access = "deny" assert_eq!( requirements.browser_use, Some(BrowserUseRequirements { + allow_webmcp: None, allow_history_access: Some(false), disable_auto_review: Some(true), allow_global_persistent_approval: Some(false), @@ -822,6 +823,7 @@ access = "deny" assert_eq!( requirements.browser_use, Some(BrowserUseRequirements { + allow_webmcp: None, allow_history_access: Some(false), disable_auto_review: None, allow_global_persistent_approval: Some(false), diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 199a9bfb10..1ccf6e8cd7 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -14,6 +14,8 @@ mod collaboration_mode_list; mod command_exec; mod compaction; mod config_requirements_application; +#[path = "config_requirements_browser_use_tests.rs"] +mod config_requirements_browser_use; mod config_requirements_in_app_browser; mod config_rpc; mod connection_handling_websocket; diff --git a/codex-rs/config/src/browser_computer_use_requirements.rs b/codex-rs/config/src/browser_computer_use_requirements.rs index 93cb869d41..f0dba54095 100644 --- a/codex-rs/config/src/browser_computer_use_requirements.rs +++ b/codex-rs/config/src/browser_computer_use_requirements.rs @@ -42,6 +42,7 @@ pub enum BrowserUseAccessApprovalLifetimeToml { #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct BrowserUseRequirementsToml { + pub allow_webmcp: Option, pub allow_history_access: Option, pub disable_auto_review: Option, pub allow_global_persistent_approval: Option, @@ -51,7 +52,8 @@ pub struct BrowserUseRequirementsToml { impl BrowserUseRequirementsToml { pub fn is_empty(&self) -> bool { - self.allow_history_access.is_none() + self.allow_webmcp.is_none() + && self.allow_history_access.is_none() && self.disable_auto_review.is_none() && self.allow_global_persistent_approval.is_none() && self @@ -108,3 +110,7 @@ impl ComputerUseRequirementsToml { }) } } + +#[cfg(test)] +#[path = "browser_computer_use_requirements_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/browser_computer_use_requirements_tests.rs b/codex-rs/config/src/browser_computer_use_requirements_tests.rs new file mode 100644 index 0000000000..7635d3d24a --- /dev/null +++ b/codex-rs/config/src/browser_computer_use_requirements_tests.rs @@ -0,0 +1,45 @@ +use crate::BrowserUseRequirementsToml; +use crate::ConfigRequirementsToml; +use pretty_assertions::assert_eq; + +#[test] +fn webmcp_requirements_preserve_explicit_values_and_omission() { + for (contents, expected_browser_use, expected_empty) in [ + ("", None, true), + ("[browser_use]", Some(None), true), + ( + "[browser_use]\nallow_webmcp = true", + Some(Some(true)), + false, + ), + ( + "[browser_use]\nallow_webmcp = false", + Some(Some(false)), + false, + ), + ] { + let requirements: ConfigRequirementsToml = + toml::from_str(contents).expect("parse managed WebMCP policy"); + assert_eq!( + requirements, + ConfigRequirementsToml { + browser_use: expected_browser_use.map(|allow_webmcp| BrowserUseRequirementsToml { + allow_webmcp, + ..Default::default() + }), + ..Default::default() + }, + ); + assert_eq!(requirements.is_empty(), expected_empty, "{contents}"); + } +} + +#[test] +fn webmcp_requirements_reject_non_booleans() { + for value in ["\"true\"", "1", "[]"] { + let contents = format!("[browser_use]\nallow_webmcp = {value}"); + let error = toml::from_str::(&contents) + .expect_err("WebMCP policy must be a boolean"); + assert!(error.to_string().contains("allow_webmcp")); + } +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 724a7c4461..089b3f8042 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -2482,6 +2482,7 @@ mod tests { assert_eq!( requirements.browser_use, Some(BrowserUseRequirementsToml { + allow_webmcp: None, allow_history_access: Some(false), disable_auto_review: Some(true), allow_global_persistent_approval: Some(false), @@ -2714,6 +2715,7 @@ mod tests { entries: BTreeMap::from([("personality".to_string(), true)]), }; let browser_use = BrowserUseRequirementsToml { + allow_webmcp: None, allow_history_access: Some(false), disable_auto_review: Some(true), allow_global_persistent_approval: None, diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 8b784bcff7..51bbae90c8 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -717,6 +717,33 @@ access = "deny" ); } +#[test] +fn webmcp_requirements_preserve_managed_layer_precedence() { + for (lower, higher, expected) in [ + ("true", "[browser_use]", true), + ("false", "[browser_use]", false), + ("true", "[browser_use]\nallow_webmcp = false", false), + ("false", "[browser_use]\nallow_webmcp = true", true), + ] { + let lower = format!("[browser_use]\nallow_webmcp = {lower}"); + let composed = compose(vec![ + layer("req_low", "Low", &lower), + layer("req_high", "High", higher), + ]) + .expect("compose managed WebMCP policy"); + assert_eq!( + composed, + Some(ConfigRequirementsToml { + browser_use: Some(crate::BrowserUseRequirementsToml { + allow_webmcp: Some(expected), + ..Default::default() + }), + ..Default::default() + }), + ); + } +} + #[test] fn windows_requirements_use_regular_toml_merge() { let composed = compose(vec![