From 61299d9752bbfae02164558ed6d65e09ed5ef897 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Fri, 29 May 2026 15:41:30 -0700 Subject: [PATCH] feat: synchronize runtime workspace roots in thread settings --- .../schema/json/ServerNotification.json | 10 ++ .../codex_app_server_protocol.schemas.json | 10 ++ .../codex_app_server_protocol.v2.schemas.json | 10 ++ .../v2/ThreadSettingsUpdatedNotification.json | 10 ++ .../schema/typescript/v2/ThreadSettings.ts | 2 +- .../src/protocol/common.rs | 7 + .../src/protocol/v2/tests.rs | 10 ++ .../src/protocol/v2/thread.rs | 9 ++ codex-rs/app-server/README.md | 2 +- .../src/request_processors/thread_summary.rs | 2 + .../src/request_processors/turn_processor.rs | 64 ++++++--- codex-rs/app-server/src/thread_state.rs | 3 + .../tests/suite/v2/thread_settings_update.rs | 131 ++++++++++++++++++ codex-rs/core/src/session/handlers.rs | 1 + codex-rs/protocol/src/protocol.rs | 38 +++++ .../tui/src/app/app_server_event_targets.rs | 1 + codex-rs/tui/src/app/tests.rs | 107 +++++++++++++- codex-rs/tui/src/app/thread_settings.rs | 10 +- codex-rs/tui/src/chatwidget/settings.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 33 ++++- 20 files changed, 439 insertions(+), 27 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1b1a53b5d4..9b9bebaab6 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4583,6 +4583,16 @@ } ] }, + "runtimeWorkspaceRoots": { + "default": null, + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, "sandboxPolicy": { "$ref": "#/definitions/SandboxPolicy" }, 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 a41df13ef8..43c6bc0d16 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 @@ -17688,6 +17688,16 @@ } ] }, + "runtimeWorkspaceRoots": { + "default": null, + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, "sandboxPolicy": { "$ref": "#/definitions/v2/SandboxPolicy" }, 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 42d6009069..8216f31421 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 @@ -15512,6 +15512,16 @@ } ] }, + "runtimeWorkspaceRoots": { + "default": null, + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, "sandboxPolicy": { "$ref": "#/definitions/SandboxPolicy" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 42a76ae364..9930d89bd0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -332,6 +332,16 @@ } ] }, + "runtimeWorkspaceRoots": { + "default": null, + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, "sandboxPolicy": { "$ref": "#/definitions/SandboxPolicy" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts index bcfd0ad86c..ae09884fcd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -11,4 +11,4 @@ import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; +export type ThreadSettings = { cwd: AbsolutePathBuf, runtimeWorkspaceRoots: Array | null, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 70a76e71ac..174b1a6216 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3167,6 +3167,7 @@ mod tests { thread_id: "thr_123".to_string(), thread_settings: v2::ThreadSettings { cwd: absolute_path("/tmp/repo"), + runtime_workspace_roots: None, approval_policy: v2::AskForApproval::Never, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox_policy: v2::SandboxPolicy::DangerFullAccess, @@ -3192,6 +3193,12 @@ mod tests { crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), Some("thread/settings/updated") ); + assert_eq!( + serde_json::to_value(notification) + .expect("thread settings notification should serialize") + .pointer("/params/threadSettings/runtimeWorkspaceRoots"), + Some(&serde_json::Value::Null) + ); } #[test] 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 16c4a93bfc..9d1048a3bd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3644,6 +3644,16 @@ fn thread_settings_update_params_preserve_explicit_null_service_tier() { #[test] fn thread_settings_update_params_preserve_field_level_experimental_gates() { + let runtime_workspace_roots = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + runtime_workspace_roots: Some(vec![PathBuf::from("repo")]), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&runtime_workspace_roots), + Some("thread/settings/update.runtimeWorkspaceRoots") + ); + let permissions = ThreadSettingsUpdateParams { thread_id: "thread_123".to_string(), permissions: Some(":workspace".to_string()), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 5d2273139a..e5cf34f93b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -234,6 +234,11 @@ pub struct ThreadSettingsUpdateParams { /// Override the working directory for subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots for subsequent turns. + /// Relative paths are resolved against the effective cwd. + #[experimental("thread/settings/update.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, /// Override the approval policy for subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -290,6 +295,10 @@ pub struct ThreadSettingsUpdateResponse {} #[ts(export_to = "v2/")] pub struct ThreadSettings { pub cwd: AbsolutePathBuf, + // New servers populate this field. `None` accepts older payloads that + // predate runtime workspace root synchronization. + #[serde(default)] + pub runtime_workspace_roots: Option>, pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub sandbox_policy: SandboxPolicy, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a0c548cfc8..4f322f9e95 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -140,7 +140,7 @@ Example with notification opt-out: - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. -- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `runtimeWorkspaceRoots` replaces the thread-scoped runtime roots used to materialize `:workspace_roots`, resolving relative paths against the effective cwd. Runtime roots are limited to 100 entries, 4096 characters per resolved root, and 1 MiB of aggregate request path text. `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 0a6c1bfb0f..8d860c30ca 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -194,6 +194,7 @@ pub(crate) fn thread_settings_from_config_snapshot( ) -> ThreadSettings { ThreadSettings { cwd: config_snapshot.cwd.clone(), + runtime_workspace_roots: Some(config_snapshot.workspace_roots.clone()), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox_policy: thread_response_sandbox_policy( @@ -222,6 +223,7 @@ pub(crate) fn thread_settings_from_core_snapshot( snapshot.cwd.as_path(), ), cwd: snapshot.cwd, + runtime_workspace_roots: Some(snapshot.runtime_workspace_roots), approval_policy: snapshot.approval_policy.into(), approvals_reviewer: snapshot.approvals_reviewer.into(), active_permission_profile: thread_response_active_permission_profile( diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 8c8ffdc370..5228879ebf 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -3,6 +3,10 @@ use codex_app_server_protocol::ThreadSettingsOverrides; use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; +const MAX_RUNTIME_WORKSPACE_ROOTS: usize = 100; +const MAX_RUNTIME_WORKSPACE_ROOT_CHARS: usize = 4096; +const MAX_RUNTIME_WORKSPACE_ROOTS_TOTAL_CHARS: usize = 1 << 20; + #[derive(Clone)] pub(crate) struct TurnRequestProcessor { auth_manager: Arc, @@ -23,15 +27,38 @@ pub(crate) struct TurnRequestProcessor { fn resolve_runtime_workspace_roots( workspace_roots: Vec, base_cwd: &AbsolutePathBuf, -) -> Vec { +) -> Result, JSONRPCErrorError> { let mut resolved_roots = Vec::new(); for path in workspace_roots { let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); + if root.as_path().to_string_lossy().chars().count() > MAX_RUNTIME_WORKSPACE_ROOT_CHARS { + return Err(invalid_params(format!( + "Runtime workspace root exceeds the maximum length of {MAX_RUNTIME_WORKSPACE_ROOT_CHARS} characters." + ))); + } if !resolved_roots.iter().any(|existing| existing == &root) { resolved_roots.push(root); } } - resolved_roots + Ok(resolved_roots) +} + +fn validate_runtime_workspace_roots(workspace_roots: &[PathBuf]) -> Result<(), JSONRPCErrorError> { + if workspace_roots.len() > MAX_RUNTIME_WORKSPACE_ROOTS { + return Err(invalid_params(format!( + "Runtime workspace roots exceed the maximum count of {MAX_RUNTIME_WORKSPACE_ROOTS}." + ))); + } + let total_chars = workspace_roots + .iter() + .map(|path| path.to_string_lossy().chars().count()) + .sum::(); + if total_chars > MAX_RUNTIME_WORKSPACE_ROOTS_TOTAL_CHARS { + return Err(invalid_params(format!( + "Runtime workspace roots exceed the maximum total length of {MAX_RUNTIME_WORKSPACE_ROOTS_TOTAL_CHARS} characters." + ))); + } + Ok(()) } fn map_additional_context( @@ -621,22 +648,23 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = if let Some(workspace_roots) = - runtime_workspace_roots_request.clone() - { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error(format!( - "{method} runtime workspace roots missing thread snapshot" - ))); + let runtime_workspace_roots = + if let Some(workspace_roots) = runtime_workspace_roots_request.clone() { + validate_runtime_workspace_roots(&workspace_roots)?; + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error(format!( + "{method} runtime workspace roots missing thread snapshot" + ))); + }; + let base_cwd = match cwd.as_ref() { + Some(cwd) => AbsolutePathBuf::relative_to_current_dir(cwd) + .map_err(|err| invalid_request(format!("invalid cwd: {err}")))?, + None => snapshot.cwd.clone(), + }; + Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)?) + } else { + None }; - let base_cwd = cwd - .as_ref() - .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) - .unwrap_or_else(|| snapshot.cwd.clone()); - Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) - } else { - None - }; let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -750,7 +778,7 @@ impl TurnRequestProcessor { ThreadSettingsBuildParams { method: "thread/settings/update", cwd: params.cwd, - runtime_workspace_roots: None, + runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 44ef484420..8186cf86a6 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -336,6 +336,9 @@ mod tests { fn thread_settings(model: &str) -> ThreadSettings { ThreadSettings { cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"), + runtime_workspace_roots: Some(vec![ + AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"), + ]), approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::ReadOnly { diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index 44f3499d1b..220207d330 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -29,6 +29,7 @@ use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::BTreeMap; +use std::path::PathBuf; use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; @@ -96,6 +97,117 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Ok(()) } +#[tokio::test] +async fn thread_settings_update_resolves_runtime_workspace_roots_against_effective_cwd() +-> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let initial_cwd = codex_home.path().join("initial"); + std::fs::create_dir_all(&initial_cwd)?; + let thread = start_thread_with_cwd(&mut mcp, &initial_cwd).await?.thread; + let cwd = PathBuf::from("workspace"); + let canonical_codex_home = std::fs::canonicalize(codex_home.path())?; + let expected_cwd = canonical_codex_home.join(&cwd); + let expected_root = expected_cwd.join("nested"); + std::fs::create_dir_all(&expected_root)?; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + cwd: Some(cwd), + runtime_workspace_roots: Some(vec![PathBuf::from("nested"), PathBuf::from("nested")]), + ..Default::default() + }, + ) + .await?; + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!( + std::fs::canonicalize(updated.thread_settings.cwd.as_path())?, + std::fs::canonicalize(expected_cwd)? + ); + assert_eq!( + updated + .thread_settings + .runtime_workspace_roots + .as_ref() + .expect("new app-server should report runtime workspace roots") + .iter() + .map(|root| std::fs::canonicalize(root.as_path())) + .collect::, _>>()?, + vec![std::fs::canonicalize(expected_root)?] + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_rejects_too_many_runtime_workspace_roots() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + runtime_workspace_roots: Some( + (0..101) + .map(|index| PathBuf::from(format!("root-{index}"))) + .collect(), + ), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + error.error.message, + "Runtime workspace roots exceed the maximum count of 100." + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_rejects_too_long_runtime_workspace_root() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + runtime_workspace_roots: Some(vec![PathBuf::from("r".repeat(4097))]), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + error.error.message, + "Runtime workspace root exceeds the maximum length of 4096 characters." + ); + Ok(()) +} + #[tokio::test] async fn thread_settings_update_while_turn_is_active_emits_notification() -> Result<()> { let server = responses::start_mock_server().await; @@ -348,6 +460,25 @@ async fn start_thread(mcp: &mut McpProcess) -> Result { to_response(response) } +async fn start_thread_with_cwd( + mcp: &mut McpProcess, + cwd: &std::path::Path, +) -> Result { + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(cwd.display().to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + async fn read_thread_with_turns( mcp: &mut McpProcess, thread_id: &str, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 7730a30ad7..d5a483eb96 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -183,6 +183,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { permission_profile: snapshot.permission_profile, active_permission_profile: snapshot.active_permission_profile, cwd: snapshot.cwd, + runtime_workspace_roots: snapshot.workspace_roots, reasoning_effort: snapshot.reasoning_effort, reasoning_summary: snapshot.reasoning_summary, personality: snapshot.personality, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c83c492e72..6809248504 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1905,6 +1905,8 @@ pub struct ThreadSettingsSnapshot { #[ts(optional)] pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + #[serde(default)] + pub runtime_workspace_roots: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -5258,6 +5260,42 @@ mod tests { Ok(()) } + #[test] + fn thread_settings_snapshot_deserializes_without_runtime_workspace_roots() -> Result<()> { + let snapshot = ThreadSettingsSnapshot { + model: "gpt-5".to_string(), + model_provider_id: "openai".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: vec![test_path_buf("/tmp").abs()], + reasoning_effort: None, + reasoning_summary: None, + personality: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: crate::config_types::Settings { + model: "gpt-5".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + }; + let mut value = serde_json::to_value(snapshot)?; + value + .as_object_mut() + .expect("thread settings snapshot should serialize as an object") + .remove("runtime_workspace_roots"); + + let snapshot: ThreadSettingsSnapshot = serde_json::from_value(value)?; + + assert!(snapshot.runtime_workspace_roots.is_empty()); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 22cf432283..91eba5431d 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -198,6 +198,7 @@ mod tests { fn test_thread_settings() -> ThreadSettings { ThreadSettings { cwd: test_path_buf("/tmp/thread-settings").abs(), + runtime_workspace_roots: Some(vec![test_path_buf("/tmp/thread-settings").abs()]), approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 7b87caa574..d06ea5063d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5182,9 +5182,19 @@ fn failed_server_queue_submission_falls_back_without_exiting_tui() -> Result<()> }) } -#[tokio::test] -async fn override_turn_context_sends_thread_settings_update() { - Box::pin(async { +#[test] +fn override_turn_context_sends_thread_settings_update() { + const WORKER_THREADS: usize = 1; + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build() + .expect("runtime"); + + runtime.block_on(Box::pin(async { let mut app = make_test_app().await; let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) @@ -5315,8 +5325,7 @@ async fn override_turn_context_sends_thread_settings_update() { .id, codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE ); - }) - .await; + })); } #[tokio::test] @@ -5410,6 +5419,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode thread_id: inactive_thread_id.to_string(), thread_settings: ThreadSettings { cwd: test_absolute_path("/tmp/thread-settings"), + runtime_workspace_roots: Some(vec![test_absolute_path("/tmp/thread-settings")]), approval_policy: AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { @@ -5446,6 +5456,10 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode .expect("inactive session should remain cached"); assert_eq!(cached_session.model, "gpt-test"); assert_eq!(cached_session.personality, Some(Personality::Pragmatic)); + assert_eq!( + cached_session.runtime_workspace_roots, + vec![test_absolute_path("/tmp/thread-settings")] + ); assert_eq!( cached_session.collaboration_mode.as_deref(), Some(&collaboration_mode) @@ -5471,6 +5485,89 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode ); } +#[tokio::test] +async fn inactive_thread_settings_notification_without_roots_retargets_implicit_root() { + let mut app = make_test_app().await; + let primary_thread_id = ThreadId::new(); + let inactive_thread_id = ThreadId::new(); + let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main")); + let mut inactive_session = + test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive")); + inactive_session.runtime_workspace_roots = vec![inactive_session.cwd.clone()]; + + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(primary_thread_id); + app.primary_session_configured = Some(primary_session.clone()); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + primary_session, + Vec::new(), + ), + ); + app.thread_event_channels.insert( + inactive_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + inactive_session, + Vec::new(), + ), + ); + + let notification = ThreadSettingsUpdatedNotification { + thread_id: inactive_thread_id.to_string(), + thread_settings: ThreadSettings { + cwd: test_absolute_path("/tmp/retargeted"), + runtime_workspace_roots: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: Some( + codex_app_server_protocol::ActivePermissionProfile::read_only(), + ), + model: "gpt-test".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: None, + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-test".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + personality: None, + }, + }; + app.enqueue_thread_notification( + inactive_thread_id, + ServerNotification::ThreadSettingsUpdated(notification), + ) + .await + .expect("settings notification should be cached"); + + let cached_session = app + .thread_event_channels + .get(&inactive_thread_id) + .expect("inactive thread channel") + .store + .lock() + .await + .session + .clone() + .expect("inactive session should remain cached"); + assert_eq!(cached_session.cwd, test_absolute_path("/tmp/retargeted")); + assert_eq!( + cached_session.runtime_workspace_roots, + vec![test_absolute_path("/tmp/retargeted")] + ); +} + #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 27c435af3f..bfa58b6d21 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -176,6 +176,14 @@ fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: } session.model_provider_id = settings.model_provider.clone(); session.service_tier = settings.service_tier.clone(); + if let Some(runtime_workspace_roots) = settings.runtime_workspace_roots.as_ref() { + session + .runtime_workspace_roots + .clone_from(runtime_workspace_roots); + session.cwd.clone_from(&settings.cwd); + } else { + session.set_cwd_retargeting_implicit_runtime_workspace_root(settings.cwd.clone()); + } session.approval_policy = settings.approval_policy; session.approvals_reviewer = settings.approvals_reviewer.to_core(); session.permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( @@ -183,7 +191,6 @@ fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: settings.cwd.as_path(), ); session.active_permission_profile = settings.active_permission_profile.clone().map(Into::into); - session.set_cwd_retargeting_implicit_runtime_workspace_root(settings.cwd.clone()); session.personality = settings.personality; let mut collaboration_mode = settings.collaboration_mode.clone(); collaboration_mode @@ -196,6 +203,7 @@ fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: fn thread_settings_update_has_changes(params: &ThreadSettingsUpdateParams) -> bool { params.cwd.is_some() + || params.runtime_workspace_roots.is_some() || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 45dbc362e9..fd43c6065d 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -510,6 +510,12 @@ impl ChatWidget { fn apply_thread_settings(&mut self, mut settings: ThreadSettings) { let cwd_changed = self.config.cwd != settings.cwd; self.apply_thread_settings_cwd(settings.cwd.clone()); + if let Some(runtime_workspace_roots) = settings.runtime_workspace_roots.clone() { + self.config.workspace_roots = runtime_workspace_roots.clone(); + self.config + .permissions + .set_workspace_roots(runtime_workspace_roots); + } self.config.model_provider_id = settings.model_provider.clone(); self.set_service_tier(settings.service_tier.clone()); self.set_approval_policy(settings.approval_policy); diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 9e9384914a..1204e1b637 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -9,6 +9,7 @@ fn thread_settings_for_test( thread_id: thread_id.to_string(), thread_settings: codex_app_server_protocol::ThreadSettings { cwd: test_path_buf("/tmp/thread-settings").abs(), + runtime_workspace_roots: Some(vec![test_path_buf("/tmp/thread-settings").abs()]), approval_policy: AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { @@ -104,9 +105,13 @@ async fn thread_settings_updated_updates_visible_state_without_transcript() { let thread_id = ThreadId::new(); chat.handle_thread_session(configured_thread_session(thread_id)); let _ = drain_insert_history(&mut rx); + let runtime_workspace_root = test_path_buf("/tmp/thread-settings-updated").abs(); + let mut notification = thread_settings_for_test("gpt-5.4", thread_id); + notification.thread_settings.runtime_workspace_roots = + Some(vec![runtime_workspace_root.clone()]); chat.handle_server_notification( - ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-5.4", thread_id)), + ServerNotification::ThreadSettingsUpdated(notification), /*replay_kind*/ None, ); @@ -136,12 +141,38 @@ async fn thread_settings_updated_updates_visible_state_without_transcript() { codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY ); assert_eq!(chat.config_ref().personality, Some(Personality::Pragmatic)); + assert_eq!( + chat.config_ref().workspace_roots, + vec![runtime_workspace_root.clone()] + ); + assert_eq!( + chat.config_ref().permissions.user_visible_workspace_roots(), + std::slice::from_ref(&runtime_workspace_root) + ); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert!( drain_insert_history(&mut rx).is_empty(), "ThreadSettingsUpdated should not render transcript history" ); + let mut notification_without_roots = thread_settings_for_test("gpt-5.4", thread_id); + notification_without_roots + .thread_settings + .runtime_workspace_roots = None; + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(notification_without_roots), + /*replay_kind*/ None, + ); + + assert_eq!( + chat.config_ref().workspace_roots, + vec![runtime_workspace_root.clone()] + ); + assert_eq!( + chat.config_ref().permissions.user_visible_workspace_roots(), + &[runtime_workspace_root] + ); + chat.handle_server_notification( ServerNotification::ThreadSettingsUpdated(thread_settings_for_test( "gpt-5.2",