Files
codex/codex-rs/app-server/tests/suite/v2/config_rpc.rs
nmccormack-oai 0f64d70808 Expose managed WebMCP policy through the app server (#42823)
## What changed

- Parse the optional `[browser_use].allow_webmcp` boolean from managed requirements and preserve requirement-layer precedence.
- Return the policy as `browserUse.allowWebmcp` from `configRequirements/read`, including explicit `true`, explicit `false`, and `null` when omitted.
- Update the generated JSON and TypeScript protocol schemas.

## Testing

- Cover parsing, invalid values, layered overrides, and app-server response serialization.

GitOrigin-RevId: a3fd7d1a6f838bb3ab0ac2a644409101a2338ca6
2026-09-04 18:30:26 +00:00

2147 lines
69 KiB
Rust

use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::test_path_buf_with_windows;
use app_test_support::test_tmp_path_buf;
use codex_app_server_protocol::AllowDenyRequirement;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppLinkConfig;
use codex_app_server_protocol::AppLinksConfig;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AppsDefaultConfig;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::BrowserUseAccessApprovalLifetime;
use codex_app_server_protocol::BrowserUseConfig;
use codex_app_server_protocol::BrowserUseOriginPolicy;
use codex_app_server_protocol::BrowserUseOriginPolicyConfig;
use codex_app_server_protocol::BrowserUseRequirements;
use codex_app_server_protocol::CliAuthCredentialsStoreMode;
use codex_app_server_protocol::ComputerUseConfig;
use codex_app_server_protocol::ComputerUseMacosConfig;
use codex_app_server_protocol::ComputerUseMacosRequirements;
use codex_app_server_protocol::ComputerUseRequirements;
use codex_app_server_protocol::ComputerUseWindowsConfig;
use codex_app_server_protocol::ComputerUseWindowsExeConfig;
use codex_app_server_protocol::ComputerUseWindowsExeRequirement;
use codex_app_server_protocol::ComputerUseWindowsRequirements;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ForcedChatgptWorkspaceIds;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ToolsV2;
use codex_app_server_protocol::WriteStatus;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchLocation;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use tempfile::TempDir;
use tokio::time::timeout;
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing config RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> {
Ok(std::fs::write(
codex_home.path().join("config.toml"),
contents,
)?)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn managed_auth_settings_are_exposed_enforced_and_read_only() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"cli_auth_credentials_store = "file"
chatgpt_base_url = "https://user.example/backend-api/"
"#,
)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"cli_auth_credentials_store = "ephemeral"
chatgpt_base_url = "https://managed.example/backend-api/"
"#,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let requirements_id = app_server.send_config_requirements_read_request().await?;
let requirements: ConfigRequirementsReadResponse = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_response(requirements_id),
)
.await??;
let requirements = requirements.requirements.expect("managed requirements");
assert_eq!(
(
requirements.cli_auth_credentials_store,
requirements.chatgpt_base_url.as_deref(),
),
(
Some(CliAuthCredentialsStoreMode::Ephemeral),
Some("https://managed.example/backend-api/"),
),
);
let config_id = app_server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let config: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(config_id)).await??;
assert_eq!(
(
config.config.additional.get("cli_auth_credentials_store"),
config.config.additional.get("chatgpt_base_url"),
),
(
Some(&json!("ephemeral")),
Some(&json!("https://managed.example/backend-api/")),
),
);
for (field, value) in [
("cli_auth_credentials_store", json!("file")),
(
"chatgpt_base_url",
json!("https://user.example/backend-api/"),
),
] {
let write_id = app_server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: field.to_string(),
value,
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_error_message(RequestId::Integer(write_id)),
)
.await??;
assert_eq!(
error
.error
.data
.as_ref()
.and_then(|data| data.get("config_write_error_code"))
.and_then(serde_json::Value::as_str),
Some("configRequirementReadonly"),
);
}
assert_eq!(
std::fs::read_to_string(codex_home.path().join("config.toml"))?,
"cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"https://user.example/backend-api/\"\n",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_requirements_read_includes_remote_control_and_managed_hooks() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"allow_remote_control = false
[hooks]
[[hooks.SessionStart]]
[[hooks.SessionStart.hooks]]
type = "command"
command = "echo managed"
additionalContextLimit = 4096
[[hooks.SessionStart.hooks]]
type = "mcp_tool"
server = "security"
tool = "scan"
input = { path = "${tool_input.file_path}", metadata = { enabled = true, retries = 2 } }
timeout = 30
statusMessage = "Scanning file"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp.send_config_requirements_read_request().await?;
let response: ConfigRequirementsReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let requirements = response
.requirements
.expect("managed requirements should be returned");
assert_eq!(requirements.allow_remote_control, Some(false));
assert_eq!(
requirements
.hooks
.expect("managed hooks should be returned")
.session_start[0]
.hooks,
vec![
ConfiguredHookHandler::Command {
command: "echo managed".to_string(),
command_windows: None,
timeout_sec: None,
r#async: false,
status_message: None,
additional_context_limit: Some(4_096),
},
ConfiguredHookHandler::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
input: serde_json::from_value(json!({
"path": "${tool_input.file_path}",
"metadata": { "enabled": true, "retries": 2 },
}))?,
timeout_sec: Some(30),
status_message: Some("Scanning file".to_string()),
},
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_requirements_read_includes_browser_and_computer_use_schema() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
allow_browser_and_computer_use = false
[browser_use]
allow_history_access = false
disable_auto_review = true
allow_global_persistent_approval = false
[browser_use.default_origin_policy]
access = "deny"
downloads = "allow"
uploads = "deny"
full_cdp_access = "allow"
auto_review = "deny"
persistent_approval = false
access_approval_lifetime = "turn"
[browser_use.origins."https://example.com"]
access = "allow"
downloads = "deny"
uploads = "allow"
full_cdp_access = "deny"
auto_review = "deny"
persistent_approval = true
access_approval_lifetime = "thread"
[computer_use]
allow_locked_computer_use = false
allow_persistent_approval = false
default_app_access = "deny"
[computer_use.macos.bundle_ids]
"com.apple.Safari" = "allow"
[computer_use.windows.aumids]
"Microsoft.Paint_8wekyb3d8bbwe!App" = "allow"
[[computer_use.windows.exes]]
publisher_name = "CN=Google LLC"
product_name = "Google Chrome"
binary_name = "chrome.exe"
access = "deny"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_config_requirements_read_request().await?;
let response: ConfigRequirementsReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let requirements = response
.requirements
.expect("managed requirements should be returned");
assert_eq!(requirements.allow_browser_and_computer_use, Some(false));
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),
default_origin_policy: Some(BrowserUseOriginPolicy {
access: Some(AllowDenyRequirement::Deny),
downloads: Some(AllowDenyRequirement::Allow),
uploads: Some(AllowDenyRequirement::Deny),
full_cdp_access: Some(AllowDenyRequirement::Allow),
auto_review: Some(AllowDenyRequirement::Deny),
persistent_approval: Some(false),
access_approval_lifetime: Some(BrowserUseAccessApprovalLifetime::Turn),
}),
origins: Some(BTreeMap::from([(
"https://example.com".to_string(),
BrowserUseOriginPolicy {
access: Some(AllowDenyRequirement::Allow),
downloads: Some(AllowDenyRequirement::Deny),
uploads: Some(AllowDenyRequirement::Allow),
full_cdp_access: Some(AllowDenyRequirement::Deny),
auto_review: Some(AllowDenyRequirement::Deny),
persistent_approval: Some(true),
access_approval_lifetime: Some(BrowserUseAccessApprovalLifetime::Thread),
},
)])),
})
);
assert_eq!(
requirements.computer_use,
Some(ComputerUseRequirements {
allow_locked_computer_use: Some(false),
allow_persistent_approval: Some(false),
default_app_access: Some(AllowDenyRequirement::Deny),
macos: Some(ComputerUseMacosRequirements {
bundle_ids: Some(BTreeMap::from([(
"com.apple.Safari".to_string(),
AllowDenyRequirement::Allow,
)])),
}),
windows: Some(ComputerUseWindowsRequirements {
aumids: Some(BTreeMap::from([(
"Microsoft.Paint_8wekyb3d8bbwe!App".to_string(),
AllowDenyRequirement::Allow,
)])),
exes: Some(vec![ComputerUseWindowsExeRequirement {
publisher_name: "CN=Google LLC".to_string(),
product_name: "Google Chrome".to_string(),
binary_name: Some("chrome.exe".to_string()),
access: AllowDenyRequirement::Deny,
}]),
}),
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_requirements_read_includes_in_app_updates_policy() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
[features]
in_app_updates = false
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_config_requirements_read_request().await?;
let response: ConfigRequirementsReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
response
.requirements
.and_then(|requirements| requirements.feature_requirements),
Some(std::collections::BTreeMap::from([(
"in_app_updates".to_string(),
false,
)]))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_requirements_read_includes_managed_model_policy_and_instructions() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
"developer_instructions = \"ordinary instructions\"\n",
)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
additional_developer_instructions = "Follow the managed policy.\nPreserve its formatting."
[auto_review]
required_on_models = ["gpt-protected", "gpt-sensitive"]
ignore_rules = ["gpt-protected"]
[models.new_thread]
model = "gpt-managed"
model_reasoning_effort = "medium"
service_tier = "fast"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp.send_config_requirements_read_request().await?;
let response: ConfigRequirementsReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let requirements = response.requirements.expect("managed requirements");
assert_eq!(
requirements.additional_developer_instructions.as_deref(),
Some("Follow the managed policy.\nPreserve its formatting.")
);
let auto_review = requirements
.auto_review
.expect("managed automatic-review requirements");
assert_eq!(
auto_review.required_on_models,
Some(vec![
"gpt-protected".to_string(),
"gpt-sensitive".to_string()
])
);
assert_eq!(
auto_review.ignore_rules,
Some(vec!["gpt-protected".to_string()])
);
let models = requirements.models.expect("managed model requirements");
let defaults = models.new_thread.expect("managed new-thread defaults");
assert_eq!(defaults.model.as_deref(), Some("gpt-managed"));
assert_eq!(
defaults.model_reasoning_effort,
Some(ReasoningEffort::Medium)
);
assert_eq!(defaults.service_tier.as_deref(), Some("fast"));
let config_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let config: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(config_id)).await??;
assert_eq!(
(
config.config.developer_instructions.as_deref(),
config
.config
.additional
.get("additional_developer_instructions"),
),
(Some("ordinary instructions"), None),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_disables_guardian_v2_when_managed_config_requires_guardian_v1() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(&codex_home, "[features]\nguardianv2 = true\n")?;
std::fs::write(
codex_home.path().join("requirements.toml"),
"allowed_approvals_reviewers = [\"auto_review\"]\n",
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let ConfigReadResponse { config, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("guardianv2")),
Some(&json!(false))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_returns_effective_and_layers() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
model = "gpt-user"
sandbox_mode = "workspace-write"
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await?;
let ConfigReadResponse {
config,
origins,
layers,
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(config.model.as_deref(), Some("gpt-user"));
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert!(
origins
.values()
.all(|origin| !matches!(&origin.name, ConfigLayerSource::PackagedDefaults { .. }))
);
let layers = layers.expect("layers present");
assert_layers_user_then_optional_system(&layers, user_file)?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_tools() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
model = "gpt-user"
[tools.web_search]
context_size = "low"
allowed_domains = ["example.com"]
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await?;
let ConfigReadResponse {
config,
origins,
layers,
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let tools = config.tools.expect("tools present");
assert_eq!(
tools,
ToolsV2 {
web_search: Some(WebSearchToolConfig {
context_size: Some(WebSearchContextSize::Low),
allowed_domains: Some(vec!["example.com".to_string()]),
location: None,
}),
}
);
assert_eq!(
origins
.get("tools.web_search.context_size")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins
.get("tools.web_search.allowed_domains.0")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
let layers = layers.expect("layers present");
assert_layers_user_then_optional_system(&layers, user_file)?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_browser_and_computer_use_config() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
[browser_use]
allow_history_access = true
[browser_use.default_origin_policy]
access = "deny"
downloads = "allow"
[browser_use.origins."https://example.com"]
downloads = "deny"
uploads = "allow"
[computer_use]
default_app_access = "deny"
[computer_use.macos.bundle_ids]
"com.apple.Safari" = "allow"
[computer_use.windows.aumids]
"Microsoft.Paint_8wekyb3d8bbwe!App" = "deny"
[[computer_use.windows.exes]]
publisher_name = "CN=Google LLC"
product_name = "Google Chrome"
binary_name = "chrome.exe"
access = "allow"
"#,
)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
allow_browser_and_computer_use = false
[browser_use]
allow_history_access = false
allow_global_persistent_approval = false
[browser_use.default_origin_policy]
access = "allow"
[computer_use]
default_app_access = "allow"
"#,
)?;
let workspace = TempDir::new()?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join("config.toml"),
r#"
[browser_use.default_origin_policy]
uploads = "deny"
full_cdp_access = "allow"
[browser_use.origins."https://example.com"]
access = "allow"
full_cdp_access = "deny"
[computer_use.macos.bundle_ids]
"com.apple.TextEdit" = "allow"
"com.apple.Safari" = "deny"
[computer_use.windows.aumids]
"Microsoft.WindowsCalculator_8wekyb3d8bbwe!App" = "allow"
[[computer_use.windows.exes]]
publisher_name = "CN=Microsoft Corporation"
product_name = "Microsoft Visual Studio Code"
access = "deny"
"#,
)?;
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let project_config = AbsolutePathBuf::try_from(project_config_dir)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: Some(workspace.path().to_string_lossy().into_owned()),
})
.await?;
let ConfigReadResponse {
config, origins, ..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
config.browser_use,
Some(BrowserUseConfig {
allow_history_access: Some(true),
default_origin_policy: Some(BrowserUseOriginPolicyConfig {
access: Some(AllowDenyRequirement::Deny),
downloads: Some(AllowDenyRequirement::Allow),
uploads: Some(AllowDenyRequirement::Deny),
full_cdp_access: Some(AllowDenyRequirement::Allow),
}),
origins: Some(BTreeMap::from([(
"https://example.com".to_string(),
BrowserUseOriginPolicyConfig {
access: Some(AllowDenyRequirement::Allow),
downloads: Some(AllowDenyRequirement::Deny),
uploads: Some(AllowDenyRequirement::Allow),
full_cdp_access: Some(AllowDenyRequirement::Deny),
},
)])),
})
);
assert_eq!(
config.computer_use,
Some(ComputerUseConfig {
default_app_access: Some(AllowDenyRequirement::Deny),
macos: Some(ComputerUseMacosConfig {
bundle_ids: Some(BTreeMap::from([
("com.apple.Safari".to_string(), AllowDenyRequirement::Deny,),
(
"com.apple.TextEdit".to_string(),
AllowDenyRequirement::Allow,
),
])),
}),
windows: Some(ComputerUseWindowsConfig {
aumids: Some(BTreeMap::from([
(
"Microsoft.Paint_8wekyb3d8bbwe!App".to_string(),
AllowDenyRequirement::Deny,
),
(
"Microsoft.WindowsCalculator_8wekyb3d8bbwe!App".to_string(),
AllowDenyRequirement::Allow,
),
])),
exes: Some(vec![ComputerUseWindowsExeConfig {
publisher_name: "CN=Microsoft Corporation".to_string(),
product_name: "Microsoft Visual Studio Code".to_string(),
binary_name: None,
access: AllowDenyRequirement::Deny,
}]),
}),
})
);
assert_eq!(
origins
.get("browser_use.default_origin_policy.access")
.expect("user policy origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins
.get("browser_use.default_origin_policy.full_cdp_access")
.expect("project policy origin")
.name,
ConfigLayerSource::Project {
dot_codex_folder: project_config.clone(),
}
);
assert_eq!(
origins
.get("browser_use.allow_history_access")
.expect("user browser use origin")
.name,
ConfigLayerSource::User {
file: user_file,
profile: None,
}
);
assert_eq!(
origins
.get("computer_use.windows.exes.0.access")
.expect("project computer use origin")
.name,
ConfigLayerSource::Project {
dot_codex_folder: project_config,
}
);
let request_id = mcp.send_config_requirements_read_request().await?;
let response: ConfigRequirementsReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let requirements = response
.requirements
.expect("managed requirements should be returned");
assert_eq!(requirements.allow_browser_and_computer_use, Some(false));
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),
default_origin_policy: Some(BrowserUseOriginPolicy {
access: Some(AllowDenyRequirement::Allow),
downloads: None,
uploads: None,
full_cdp_access: None,
auto_review: None,
persistent_approval: None,
access_approval_lifetime: None,
}),
origins: None,
})
);
assert_eq!(
requirements.computer_use,
Some(ComputerUseRequirements {
allow_locked_computer_use: None,
allow_persistent_approval: None,
default_app_access: Some(AllowDenyRequirement::Allow),
macos: None,
windows: None,
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_accepts_legacy_forced_chatgpt_workspace_id() -> Result<()> {
const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174000";
let codex_home = TempDir::new()?;
write_config(
&codex_home,
&format!(
r#"
forced_chatgpt_workspace_id = "{WORKSPACE_ID}"
"#
),
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let ConfigReadResponse { config, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
config.forced_chatgpt_workspace_id,
Some(ForcedChatgptWorkspaceIds::Single(WORKSPACE_ID.to_string()))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_accepts_forced_chatgpt_workspace_id_list() -> Result<()> {
const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000";
const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001";
let codex_home = TempDir::new()?;
write_config(
&codex_home,
&format!(
r#"
forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"]
"#
),
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let ConfigReadResponse { config, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
config.forced_chatgpt_workspace_id,
Some(ForcedChatgptWorkspaceIds::Multiple(vec![
WORKSPACE_ID_A.to_string(),
WORKSPACE_ID_B.to_string(),
]))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_nested_web_search_tool_config() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
web_search = "live"
[tools.web_search]
context_size = "high"
allowed_domains = ["example.com"]
location = { country = "US", city = "New York", timezone = "America/New_York" }
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let ConfigReadResponse { config, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
config.tools.expect("tools present").web_search,
Some(WebSearchToolConfig {
context_size: Some(WebSearchContextSize::High),
allowed_domains: Some(vec!["example.com".to_string()]),
location: Some(WebSearchLocation {
country: Some("US".to_string()),
region: None,
city: Some("New York".to_string()),
timezone: Some("America/New_York".to_string()),
}),
}),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_ignores_bool_web_search_tool_config() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
[tools]
web_search = true
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let ConfigReadResponse { config, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(config.tools.expect("tools present").web_search, None,);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_apps() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
[apps._default]
approvals_reviewer = "auto_review"
default_tools_approval_mode = "writes"
[apps.app1]
enabled = false
approvals_reviewer = "user"
destructive_enabled = false
default_tools_approval_mode = "prompt"
[apps.app1.links.link_work]
approvals_reviewer = "auto_review"
default_tools_approval_mode = "approve"
[apps.app1.links.link_personal]
default_tools_approval_mode = "writes"
[apps.app_without_links]
enabled = true
[apps.app_with_empty_links.links]
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await?;
let response: serde_json::Value =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
response["config"]["apps"]["app_without_links"].get("links"),
Some(&json!(null)),
);
assert_eq!(
response["config"]["apps"]["app_with_empty_links"].get("links"),
Some(&json!({})),
);
let ConfigReadResponse {
config,
origins,
layers,
} = serde_json::from_value(response)?;
assert_eq!(
config.apps,
Some(AppsConfig {
default: Some(AppsDefaultConfig {
enabled: true,
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: Some(AppToolApproval::Writes),
}),
apps: std::collections::HashMap::from([
(
"app1".to_string(),
AppConfig {
enabled: false,
approvals_reviewer: Some(ApprovalsReviewer::User),
destructive_enabled: Some(false),
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
default_tools_enabled: None,
tools: None,
links: Some(AppLinksConfig {
links: std::collections::HashMap::from([
(
"link_work".to_string(),
AppLinkConfig {
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
default_tools_approval_mode: Some(AppToolApproval::Approve),
},
),
(
"link_personal".to_string(),
AppLinkConfig {
approvals_reviewer: None,
default_tools_approval_mode: Some(AppToolApproval::Writes),
},
),
]),
}),
},
),
(
"app_without_links".to_string(),
AppConfig {
enabled: true,
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: None,
links: None,
},
),
(
"app_with_empty_links".to_string(),
AppConfig {
enabled: true,
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: None,
links: Some(AppLinksConfig {
links: std::collections::HashMap::new(),
}),
},
),
]),
})
);
assert_eq!(
origins
.get("apps._default.approvals_reviewer")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins
.get("apps._default.default_tools_approval_mode")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins.get("apps.app1.enabled").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins
.get("apps.app1.approvals_reviewer")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins
.get("apps.app1.destructive_enabled")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins
.get("apps.app1.default_tools_approval_mode")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
let layers = layers.expect("layers present");
assert_layers_user_then_optional_system(&layers, user_file)?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_desktop_settings() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
[desktop]
appearanceTheme = "dark"
selected-avatar-id = "codex"
[desktop.workspace]
collapsed = true
width = 320
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let ConfigReadResponse { config, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let desktop = config.desktop.expect("desktop settings present");
assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark")));
assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex")));
assert_eq!(
desktop.get("workspace"),
Some(&json!({
"collapsed": true,
"width": 320,
}))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_project_layers_for_cwd() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(&codex_home, r#"model = "gpt-user""#)?;
let workspace = TempDir::new()?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join("config.toml"),
r#"
model_reasoning_effort = "high"
"#,
)?;
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
let project_config = AbsolutePathBuf::try_from(project_config_dir)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: Some(workspace.path().to_string_lossy().into_owned()),
})
.await?;
let ConfigReadResponse {
config, origins, ..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
origins.get("model_reasoning_effort").expect("origin").name,
ConfigLayerSource::Project {
dot_codex_folder: project_config
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_respects_managed_project_root_markers() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(&codex_home, "model_context_window = 16384\n")?;
let workspace = TempDir::new()?;
let ancestor_config = workspace.path().join(".codex");
let child = workspace.path().join("child");
let child_config = child.join(".codex");
for dir in [
workspace.path().join(".git"),
ancestor_config.clone(),
child_config.clone(),
] {
std::fs::create_dir_all(dir)?;
}
std::fs::write(workspace.path().join(".git/HEAD"), "ref: refs/heads/main\n")?;
std::fs::write(
ancestor_config.join("config.toml"),
"model_context_window = 32768\n",
)?;
std::fs::write(
child_config.join("config.toml"),
"model_reasoning_effort = \"high\"\n",
)?;
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
let managed_path = codex_home.path().join("managed_config.toml");
std::fs::write(&managed_path, "project_root_markers = []\n")?;
let managed_path = managed_path.to_string_lossy().into_owned();
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("CODEX_APP_SERVER_MANAGED_CONFIG_PATH", Some(&managed_path))])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = app_server
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: Some(child.to_string_lossy().into_owned()),
})
.await?;
let ConfigReadResponse { config, layers, .. } =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
assert_eq!(
(
config.additional.get("project_root_markers"),
config.model_context_window,
config.model_reasoning_effort,
),
(Some(&json!([])), Some(16384), Some(ReasoningEffort::High))
);
let project_layers = layers
.expect("layers present")
.into_iter()
.filter_map(|layer| {
if let ConfigLayerSource::Project { dot_codex_folder } = layer.name {
Some((dot_codex_folder, layer.config, layer.disabled_reason))
} else {
None
}
})
.collect::<Vec<_>>();
assert_eq!(
project_layers,
vec![(
AbsolutePathBuf::try_from(child_config)?,
json!({"model_reasoning_effort": "high"}),
None,
)]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_system_layer_and_overrides() -> Result<()> {
let codex_home = TempDir::new()?;
let user_dir = test_path_buf_with_windows("/user", Some(r"C:\Users\user"));
let system_dir = test_path_buf_with_windows("/system", Some(r"C:\System"));
write_config(
&codex_home,
&format!(
r#"
model = "gpt-user"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
writable_roots = [{}]
network_access = true
"#,
serde_json::json!(user_dir)
),
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let managed_path = codex_home.path().join("managed_config.toml");
let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?;
std::fs::write(
&managed_path,
format!(
r#"
model = "gpt-system"
approval_policy = "never"
[sandbox_workspace_write]
writable_roots = [{}]
"#,
serde_json::json!(system_dir.clone())
),
)?;
let managed_path_str = managed_path.display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[(
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
Some(&managed_path_str),
)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await?;
let ConfigReadResponse {
config,
origins,
layers,
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(config.model.as_deref(), Some("gpt-system"));
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
assert_eq!(config.approval_policy, Some(AskForApproval::Never));
assert_eq!(
origins.get("approval_policy").expect("origin").name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite));
assert_eq!(
origins.get("sandbox_mode").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
let sandbox = config
.sandbox_workspace_write
.as_ref()
.expect("sandbox workspace write");
assert_eq!(sandbox.writable_roots, vec![system_dir]);
assert_eq!(
origins
.get("sandbox_workspace_write.writable_roots.0")
.expect("origin")
.name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
assert!(sandbox.network_access);
assert_eq!(
origins
.get("sandbox_workspace_write.network_access")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
let layers = layers.expect("layers present");
assert_layers_managed_user_then_optional_system(&layers, managed_file, user_file)?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_replaces_value() -> Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
write_config(
&temp_dir,
r#"
model = "gpt-old"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let expected_version = read.origins.get("model").map(|m| m.version.clone());
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: json!("gpt-new"),
merge_strategy: MergeStrategy::Replace,
expected_version,
})
.await?;
let write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(write_id)).await??;
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home);
assert_eq!(write.status, WriteStatus::Ok);
assert_eq!(write.file_path, expected_file_path);
assert!(write.overridden_metadata.is_none());
let verify_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let verify: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(verify_id)).await??;
assert_eq!(verify.config.model.as_deref(), Some("gpt-new"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_updates_desktop_settings() -> Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
write_config(&temp_dir, "")?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: "desktop.appearanceTheme".to_string(),
value: json!("dark"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(write_id)).await??;
assert_eq!(write.status, WriteStatus::Ok);
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let desktop = read.config.desktop.expect("desktop settings present");
assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark")));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
write_config(
&temp_dir,
r#"
model = "gpt-old"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: json!("gpt-new"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(write_id)).await??;
assert_eq!(write.status, WriteStatus::Ok);
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(read.config.model.as_deref(), Some("gpt-new"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_rejects_version_conflict() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
model = "gpt-old"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
key_path: "model".to_string(),
value: json!("gpt-new"),
merge_strategy: MergeStrategy::Replace,
expected_version: Some("sha256:stale".to_string()),
})
.await?;
let err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(write_id)),
)
.await??;
let code = err
.error
.data
.as_ref()
.and_then(|d| d.get("config_write_error_code"))
.and_then(|v| v.as_str());
assert_eq!(code, Some("configVersionConflict"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_applies_multiple_edits() -> Result<()> {
let tmp_dir = TempDir::new()?;
let codex_home = tmp_dir.path().canonicalize()?;
write_config(&tmp_dir, "")?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let writable_root = test_tmp_path_buf();
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: Some(codex_home.join("config.toml").display().to_string()),
edits: vec![
ConfigEdit {
key_path: "sandbox_mode".to_string(),
value: json!("workspace-write"),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "sandbox_workspace_write".to_string(),
value: json!({
"writable_roots": [writable_root.clone()],
"network_access": false
}),
merge_strategy: MergeStrategy::Replace,
},
],
expected_version: None,
reload_user_config: false,
})
.await?;
let batch_write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(batch_id)).await??;
assert_eq!(batch_write.status, WriteStatus::Ok);
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home);
assert_eq!(batch_write.file_path, expected_file_path);
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite));
let sandbox = read
.config
.sandbox_workspace_write
.as_ref()
.expect("sandbox workspace write");
assert_eq!(sandbox.writable_roots, vec![writable_root]);
assert!(!sandbox.network_access);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_round_trips_browser_and_computer_use_config() -> Result<()> {
let tmp_dir = TempDir::new()?;
let codex_home = tmp_dir.path().canonicalize()?;
write_config(
&tmp_dir,
r#"
model = "gpt-existing"
[browser_use.origins."https://stale.example"]
access = "deny"
[computer_use.macos.bundle_ids]
"com.example.Stale" = "deny"
[computer_use.windows.aumids]
"Stale.App_123!App" = "allow"
[[computer_use.windows.exes]]
publisher_name = "CN=Stale"
product_name = "Stale App"
binary_name = "stale.exe"
access = "deny"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: None,
edits: vec![
ConfigEdit {
key_path: "browser_use.allow_history_access".to_string(),
value: json!(true),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "browser_use.default_origin_policy".to_string(),
value: json!({
"access": "allow",
"downloads": "deny",
"uploads": "allow",
"full_cdp_access": "deny",
}),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "browser_use.origins.\"https://example.com\"".to_string(),
value: json!({
"access": "deny",
"downloads": "allow",
"uploads": "deny",
"full_cdp_access": "allow",
}),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "browser_use.origins.\"https://stale.example\"".to_string(),
value: json!(null),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "computer_use.default_app_access".to_string(),
value: json!("deny"),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "computer_use.macos.bundle_ids.\"com.apple.Safari\"".to_string(),
value: json!("allow"),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "computer_use.macos.bundle_ids.\"com.example.Stale\"".to_string(),
value: json!(null),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "computer_use.windows.aumids.\"Microsoft.Paint_8wekyb3d8bbwe!App\""
.to_string(),
value: json!("deny"),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "computer_use.windows.aumids.\"Stale.App_123!App\"".to_string(),
value: json!(null),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "computer_use.windows.exes".to_string(),
value: json!([
{
"publisher_name": "CN=Google LLC",
"product_name": "Google Chrome",
"binary_name": "chrome.exe",
"access": "allow",
},
{
"publisher_name": "CN=Microsoft Corporation",
"product_name": "Microsoft Visual Studio Code",
"access": "deny",
},
]),
merge_strategy: MergeStrategy::Replace,
},
],
expected_version: None,
reload_user_config: false,
})
.await?;
let batch_write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(batch_id)).await??;
assert_eq!(batch_write.status, WriteStatus::Ok);
assert_eq!(
batch_write.file_path,
AbsolutePathBuf::resolve_path_against_base("config.toml", &codex_home)
);
assert_eq!(batch_write.overridden_metadata, None);
let persisted: toml::Value =
toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?;
let expected_persisted: toml::Value = toml::from_str(
r#"
model = "gpt-existing"
[browser_use]
allow_history_access = true
[browser_use.default_origin_policy]
access = "allow"
downloads = "deny"
uploads = "allow"
full_cdp_access = "deny"
[browser_use.origins."https://example.com"]
access = "deny"
downloads = "allow"
uploads = "deny"
full_cdp_access = "allow"
[computer_use]
default_app_access = "deny"
[computer_use.macos.bundle_ids]
"com.apple.Safari" = "allow"
[computer_use.windows.aumids]
"Microsoft.Paint_8wekyb3d8bbwe!App" = "deny"
[[computer_use.windows.exes]]
publisher_name = "CN=Google LLC"
product_name = "Google Chrome"
binary_name = "chrome.exe"
access = "allow"
[[computer_use.windows.exes]]
publisher_name = "CN=Microsoft Corporation"
product_name = "Microsoft Visual Studio Code"
access = "deny"
"#,
)?;
assert_eq!(persisted, expected_persisted);
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(
read.config.browser_use,
Some(BrowserUseConfig {
allow_history_access: Some(true),
default_origin_policy: Some(BrowserUseOriginPolicyConfig {
access: Some(AllowDenyRequirement::Allow),
downloads: Some(AllowDenyRequirement::Deny),
uploads: Some(AllowDenyRequirement::Allow),
full_cdp_access: Some(AllowDenyRequirement::Deny),
}),
origins: Some(BTreeMap::from([(
"https://example.com".to_string(),
BrowserUseOriginPolicyConfig {
access: Some(AllowDenyRequirement::Deny),
downloads: Some(AllowDenyRequirement::Allow),
uploads: Some(AllowDenyRequirement::Deny),
full_cdp_access: Some(AllowDenyRequirement::Allow),
},
)])),
})
);
assert_eq!(
read.config.computer_use,
Some(ComputerUseConfig {
default_app_access: Some(AllowDenyRequirement::Deny),
macos: Some(ComputerUseMacosConfig {
bundle_ids: Some(BTreeMap::from([(
"com.apple.Safari".to_string(),
AllowDenyRequirement::Allow,
)])),
}),
windows: Some(ComputerUseWindowsConfig {
aumids: Some(BTreeMap::from([(
"Microsoft.Paint_8wekyb3d8bbwe!App".to_string(),
AllowDenyRequirement::Deny,
)])),
exes: Some(vec![
ComputerUseWindowsExeConfig {
publisher_name: "CN=Google LLC".to_string(),
product_name: "Google Chrome".to_string(),
binary_name: Some("chrome.exe".to_string()),
access: AllowDenyRequirement::Allow,
},
ComputerUseWindowsExeConfig {
publisher_name: "CN=Microsoft Corporation".to_string(),
product_name: "Microsoft Visual Studio Code".to_string(),
binary_name: None,
access: AllowDenyRequirement::Deny,
},
]),
}),
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_rejects_legacy_profile_tables() -> Result<()> {
let tmp_dir = TempDir::new()?;
let codex_home = tmp_dir.path().canonicalize()?;
write_config(
&tmp_dir,
r#"
[profiles."team.prod"]
model = "gpt-5.3-spark"
"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: Some(codex_home.join("config.toml").display().to_string()),
edits: vec![
ConfigEdit {
key_path: "profiles.\"team.prod\".model".to_string(),
value: json!("gpt-5.5"),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "items.sample@catalog.enabled".to_string(),
value: json!(true),
merge_strategy: MergeStrategy::Replace,
},
],
expected_version: None,
reload_user_config: false,
})
.await?;
let err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(batch_id)),
)
.await??;
let code = err
.error
.data
.as_ref()
.and_then(|data| data.get("config_write_error_code"))
.and_then(|value| value.as_str());
assert_eq!(code, Some("configValidationError"));
assert!(
err.error.message.contains("`profiles`"),
"unexpected error: {err:?}"
);
let config: toml::Value =
toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?;
assert_eq!(
config["profiles"]["team.prod"]["model"].as_str(),
Some("gpt-5.3-spark")
);
assert_eq!(config.get("items"), None);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> {
let tmp_dir = TempDir::new()?;
let codex_home = tmp_dir.path().canonicalize()?;
write_config(&tmp_dir, "")?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: Some(codex_home.join("config.toml").display().to_string()),
edits: vec![
ConfigEdit {
key_path: "desktop.selected-avatar-id".to_string(),
value: json!("codex"),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "desktop.workspace".to_string(),
value: json!({
"collapsed": true,
"width": 320,
}),
merge_strategy: MergeStrategy::Replace,
},
],
expected_version: None,
reload_user_config: false,
})
.await?;
let batch_write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(batch_id)).await??;
assert_eq!(batch_write.status, WriteStatus::Ok);
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let desktop = read.config.desktop.expect("desktop settings present");
assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex")));
assert_eq!(
desktop.get("workspace"),
Some(&json!({
"collapsed": true,
"width": 320,
}))
);
Ok(())
}
fn assert_layers_user_then_optional_system(
layers: &[codex_app_server_protocol::ConfigLayer],
user_file: AbsolutePathBuf,
) -> Result<()> {
let mut first_index = 0;
if matches!(
layers.first().map(|layer| &layer.name),
Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm)
) {
first_index = 1;
}
assert_eq!(layers.len(), first_index + 2);
assert_eq!(
layers[first_index].name,
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
assert!(matches!(
layers[first_index + 1].name,
ConfigLayerSource::System { .. }
));
Ok(())
}
fn assert_layers_managed_user_then_optional_system(
layers: &[codex_app_server_protocol::ConfigLayer],
managed_file: AbsolutePathBuf,
user_file: AbsolutePathBuf,
) -> Result<()> {
let mut first_index = 0;
if matches!(
layers.first().map(|layer| &layer.name),
Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm)
) {
first_index = 1;
}
assert_eq!(layers.len(), first_index + 3);
assert_eq!(
layers[first_index].name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(
layers[first_index + 1].name,
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
assert!(matches!(
layers[first_index + 2].name,
ConfigLayerSource::System { .. }
));
Ok(())
}