mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## 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
2147 lines
69 KiB
Rust
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(())
|
|
}
|