diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8bcd2edd6d..603b08d343 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7086,6 +7086,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a7b8a26007..187650c913 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3475,6 +3475,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c21d146a4a..81364a6f40 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -228,6 +228,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 50d652cea5..ba24663e87 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -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 | { [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 | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index ca929b02a9..b46515d811 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -279,6 +279,7 @@ pub struct Config { #[experimental("config/read.apps")] #[serde(default)] pub apps: Option, + pub desktop: Option>, #[serde(default, flatten)] pub additional: HashMap, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index f7041cc721..f32c483ec7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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(), }); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 19d4fef01a..bd7dd186fe 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index f95b9fc6dc..38ba12f95f 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -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, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 851ec52bfd..72641150dd 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -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, + /// Opaque desktop settings stored alongside the rest of config.toml. + #[serde(default)] + pub desktop: Option>, + /// OTEL configuration. pub otel: Option, diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs index 4621b6b2e5..4d5a62df25 100644 --- a/codex-rs/config/src/strict_config_tests.rs +++ b/codex-rs/config/src/strict_config_tests.rs @@ -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::(path, contents); + + assert_eq!(error, None); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d802fa4e65..0387b2e401 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7700ea230e..6412bff01e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4795,6 +4795,57 @@ approval_mode = "approve" ); } +#[test] +fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> { + let parsed = toml::from_str::( + 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::(&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()?;