[codex] Add opaque desktop config namespace (#22584)

## 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`
This commit is contained in:
guinness-oai
2026-05-14 19:34:21 -07:00
committed by GitHub
Unverified
parent 3a23e87e20
commit 4f2918dd7f
12 changed files with 261 additions and 4 deletions
@@ -7086,6 +7086,13 @@
"null"
]
},
"desktop": {
"additionalProperties": true,
"type": [
"object",
"null"
]
},
"developer_instructions": {
"type": [
"string",
@@ -3475,6 +3475,13 @@
"null"
]
},
"desktop": {
"additionalProperties": true,
"type": [
"object",
"null"
]
},
"developer_instructions": {
"type": [
"string",
@@ -228,6 +228,13 @@
"null"
]
},
"desktop": {
"additionalProperties": true,
"type": [
"object",
"null"
]
},
"developer_instructions": {
"type": [
"string",
+1 -1
View File
@@ -20,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c
* [UNSTABLE] Optional default for where approval requests are routed for
* review.
*/
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -279,6 +279,7 @@ pub struct Config {
#[experimental("config/read.apps")]
#[serde(default)]
pub apps: Option<AppsConfig>,
pub desktop: Option<HashMap<String, JsonValue>>,
#[serde(default, flatten)]
pub additional: HashMap<String, JsonValue>,
}
@@ -1531,6 +1531,7 @@ fn config_granular_approval_policy_is_marked_experimental() {
service_tier: None,
analytics: None,
apps: None,
desktop: None,
additional: HashMap::new(),
});
@@ -1564,6 +1565,7 @@ fn config_approvals_reviewer_is_marked_experimental() {
service_tier: None,
analytics: None,
apps: None,
desktop: None,
additional: HashMap::new(),
});
@@ -1619,6 +1621,7 @@ fn config_nested_profile_granular_approval_policy_is_marked_experimental() {
service_tier: None,
analytics: None,
apps: None,
desktop: None,
additional: HashMap::new(),
});
@@ -1668,6 +1671,7 @@ fn config_nested_profile_approvals_reviewer_is_marked_experimental() {
service_tier: None,
analytics: None,
apps: None,
desktop: None,
additional: HashMap::new(),
});
+3 -3
View File
@@ -216,11 +216,11 @@ Example with notification opt-out:
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering.
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -411,6 +411,52 @@ default_tools_approval_mode = "prompt"
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()?;
@@ -649,6 +695,50 @@ model = "gpt-old"
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()?;
@@ -803,6 +893,70 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
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,
+5
View File
@@ -58,6 +58,7 @@ use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use serde_json::Value as JsonValue;
const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [
AMAZON_BEDROCK_PROVIDER_ID,
@@ -474,6 +475,10 @@ pub struct ConfigToml {
#[serde(default)]
pub apps: Option<AppsConfigToml>,
/// Opaque desktop settings stored alongside the rest of config.toml.
#[serde(default)]
pub desktop: Option<HashMap<String, JsonValue>>,
/// OTEL configuration.
pub otel: Option<OtelConfigToml>,
@@ -110,3 +110,18 @@ foo = true"#;
)
);
}
#[test]
fn strict_config_accepts_opaque_desktop_keys() {
let path = Path::new("/tmp/config.toml");
let contents = r#"
[desktop]
appearanceTheme = "dark"
[desktop.workspace]
collapsed = true"#;
let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents);
assert_eq!(error, None);
}
+6
View File
@@ -4073,6 +4073,12 @@
"description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.",
"type": "string"
},
"desktop": {
"additionalProperties": true,
"default": null,
"description": "Opaque desktop settings stored alongside the rest of config.toml.",
"type": "object"
},
"developer_instructions": {
"default": null,
"description": "Developer instructions inserted as a `developer` role message.",
+51
View File
@@ -4795,6 +4795,57 @@ approval_mode = "approve"
);
}
#[test]
fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> {
let parsed = toml::from_str::<ConfigToml>(
r#"
[desktop]
appearanceTheme = "dark"
selected-avatar-id = "codex"
recentViews = ["threads", "settings"]
[desktop.workspace]
collapsed = true
width = 320
pane = { selected = "console", expanded = false }
"#,
)?;
let desktop = parsed
.desktop
.as_ref()
.expect("desktop settings should deserialize");
assert_eq!(
desktop.get("appearanceTheme"),
Some(&serde_json::json!("dark"))
);
assert_eq!(
desktop.get("selected-avatar-id"),
Some(&serde_json::json!("codex"))
);
assert_eq!(
desktop.get("recentViews"),
Some(&serde_json::json!(["threads", "settings"]))
);
assert_eq!(
desktop.get("workspace"),
Some(&serde_json::json!({
"collapsed": true,
"width": 320,
"pane": {
"selected": "console",
"expanded": false,
},
}))
);
let serialized = toml::to_string(&parsed)?;
let reparsed = toml::from_str::<ConfigToml>(&serialized)?;
assert_eq!(reparsed.desktop, parsed.desktop);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;