mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
## Summary - reserve an explicit opaque `desktop` namespace in `ConfigToml` - expose `desktop` directly in the app-server v2 `config/read` response - keep `config/value/write` and `config/batchWrite` as the only mutation seam for paths like `desktop.someKey` - regenerate the config/app-server schema outputs and document the new contract ## Why The desktop settings work wants one durable, user-editable home for app-owned preferences in `~/.codex/config.toml`, without forcing Rust to model every individual desktop setting key. This PR is only the enabling Rust/app-server layer. It gives the Electron app a first-class config namespace it can read and write through the existing config APIs, while leaving the actual desktop migration to the app PR. ## Behavior and design notes - **Opaque but explicit:** `desktop` is first-class at the typed config root, while its children remain app-owned and open-ended. - **Strict validation still works:** arbitrary nested `desktop.*` keys are accepted instead of being rejected as unknown config. - **Existing config APIs stay the seam:** `config/read` returns the bag, and dotted writes such as `desktop.someKey` continue to flow through `config/value/write` / `config/batchWrite` rather than a bespoke RPC. - **No new consumer behavior:** Core/TUI do not start depending on desktop preferences. This only preserves and exposes the namespace for callers that intentionally use it. - **Same persistence machinery:** hand-edited `config.toml` keeps using the existing TOML edit/write path; this PR does not introduce a second serializer or side channel. - **TOML-friendly values:** the namespace is intended for ordinary JSON-shaped setting values that map cleanly into TOML: strings, numbers, booleans, arrays, and nested object/table values. This PR does not add special handling for TOML-only edge cases such as datetimes. ## Layering semantics Reads keep using the ordinary effective config pipeline, so `desktop` participates in the same layered `config/read` behavior as the rest of `ConfigToml`. Writes still target user config through the existing config service. ## Why this is the shape The alternative would be teaching Rust about each desktop setting as it is added. That would make ordinary app preferences into a cross-repo change, which is exactly the coupling we want to avoid. This keeps the contract small: 1. Rust owns one opaque `desktop` namespace in `config.toml`. 2. The desktop app owns the schema and meaning of individual keys inside it. 3. The existing config APIs remain the transport and mutation surface. That is the piece the desktop settings PR needs in order to move forward cleanly. ## Verification - `cargo test -p codex-config strict_config_accepts_opaque_desktop_keys` - `cargo test -p codex-core desktop_toml_round_trips_opaque_nested_values` - `cargo test -p codex-core config_schema_matches_fixture` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server --test all desktop_settings`
1016 lines
31 KiB
Rust
1016 lines
31 KiB
Rust
use anyhow::Result;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::test_path_buf_with_windows;
|
|
use app_test_support::test_tmp_path_buf;
|
|
use app_test_support::to_response;
|
|
use codex_app_server_protocol::AppConfig;
|
|
use codex_app_server_protocol::AppToolApproval;
|
|
use codex_app_server_protocol::AppsConfig;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
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::ConfigValueWriteParams;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::ForcedChatgptWorkspaceIds;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
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 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 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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse {
|
|
config,
|
|
origins,
|
|
layers,
|
|
} = to_response(resp)?;
|
|
|
|
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,
|
|
}
|
|
);
|
|
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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse {
|
|
config,
|
|
origins,
|
|
layers,
|
|
} = to_response(resp)?;
|
|
|
|
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_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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse { config, .. } = to_response(resp)?;
|
|
|
|
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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse { config, .. } = to_response(resp)?;
|
|
|
|
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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse { config, .. } = to_response(resp)?;
|
|
|
|
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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse { config, .. } = to_response(resp)?;
|
|
|
|
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.app1]
|
|
enabled = false
|
|
destructive_enabled = false
|
|
default_tools_approval_mode = "prompt"
|
|
"#,
|
|
)?;
|
|
let codex_home_path = codex_home.path().canonicalize()?;
|
|
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse {
|
|
config,
|
|
origins,
|
|
layers,
|
|
} = to_response(resp)?;
|
|
|
|
assert_eq!(
|
|
config.apps,
|
|
Some(AppsConfig {
|
|
default: None,
|
|
apps: std::collections::HashMap::from([(
|
|
"app1".to_string(),
|
|
AppConfig {
|
|
enabled: false,
|
|
destructive_enabled: Some(false),
|
|
open_world_enabled: None,
|
|
default_tools_approval_mode: Some(AppToolApproval::Prompt),
|
|
default_tools_enabled: None,
|
|
tools: 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.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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse { config, .. } = to_response(resp)?;
|
|
|
|
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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: Some(workspace.path().to_string_lossy().into_owned()),
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse {
|
|
config, origins, ..
|
|
} = to_response(resp)?;
|
|
|
|
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_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 = McpProcess::new_with_env(
|
|
codex_home.path(),
|
|
&[(
|
|
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
|
|
Some(&managed_path_str),
|
|
)],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ConfigReadResponse {
|
|
config,
|
|
origins,
|
|
layers,
|
|
} = to_response(resp)?;
|
|
|
|
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 = McpProcess::new(&codex_home).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let read_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let read_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let read: ConfigReadResponse = to_response(read_resp)?;
|
|
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_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
|
|
)
|
|
.await??;
|
|
let write: ConfigWriteResponse = to_response(write_resp)?;
|
|
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_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(verify_id)),
|
|
)
|
|
.await??;
|
|
let verify: ConfigReadResponse = to_response(verify_resp)?;
|
|
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 = McpProcess::new(&codex_home).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).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_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
|
|
)
|
|
.await??;
|
|
let write: ConfigWriteResponse = to_response(write_resp)?;
|
|
assert_eq!(write.status, WriteStatus::Ok);
|
|
|
|
let read_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let read_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let read: ConfigReadResponse = to_response(read_resp)?;
|
|
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 = McpProcess::new(&codex_home).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).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_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
|
|
)
|
|
.await??;
|
|
let write: ConfigWriteResponse = to_response(write_resp)?;
|
|
assert_eq!(write.status, WriteStatus::Ok);
|
|
|
|
let read_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let read: ConfigReadResponse = to_response(read_resp)?;
|
|
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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).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 = McpProcess::new(&codex_home).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).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_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(batch_id)),
|
|
)
|
|
.await??;
|
|
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
|
|
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_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let read: ConfigReadResponse = to_response(read_resp)?;
|
|
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_updates_multiple_desktop_settings() -> Result<()> {
|
|
let tmp_dir = TempDir::new()?;
|
|
let codex_home = tmp_dir.path().canonicalize()?;
|
|
write_config(&tmp_dir, "")?;
|
|
|
|
let mut mcp = McpProcess::new(&codex_home).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).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_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(batch_id)),
|
|
)
|
|
.await??;
|
|
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
|
|
assert_eq!(batch_write.status, WriteStatus::Ok);
|
|
|
|
let read_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let read_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let read: ConfigReadResponse = to_response(read_resp)?;
|
|
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(())
|
|
}
|