From bbf3179cc4485c8258384f8f9ae3a16cbffbd046 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sun, 14 Jun 2026 08:45:24 +0000 Subject: [PATCH] app-server: return environment-native workspace roots --- .../src/protocol/v2/thread.rs | 2 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 2 + .../request_processors/thread_processor.rs | 7 +- .../thread_response_paths.rs | 47 +++++++++++++ .../app-server/tests/suite/v2/thread_start.rs | 58 ++++++++++++++- .../remote_env_windows_test.rs | 8 +-- codex-rs/tui/src/app_server_session.rs | 70 +++++++++++++++---- 8 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server/src/request_processors/thread_response_paths.rs 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 b990662349..d383f25669 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -161,7 +161,7 @@ pub struct ThreadStartResponse { /// `:workspace_roots`. #[experimental("thread/start.runtimeWorkspaceRoots")] #[serde(default)] - pub runtime_workspace_roots: Vec, + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 336f769d71..4376762777 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -133,7 +133,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. In a `thread/start` response, the root corresponding to a selected environment's `cwd` uses that environment's native path spelling. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 6808b05852..09ed00ca6f 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -495,6 +495,7 @@ mod process_exec_processor; mod remote_control_processor; mod search; mod thread_processor; +mod thread_response_paths; mod token_usage_replay; mod turn_processor; mod windows_sandbox_processor; @@ -598,6 +599,7 @@ use self::config_errors::*; use self::request_errors::*; use self::thread_goal_processor::api_thread_goal_from_state; use self::thread_lifecycle::*; +use self::thread_response_paths::thread_response_runtime_workspace_roots; use self::thread_resume_redaction::*; use self::thread_summary::*; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index ff9ae13947..f925f9b2ca 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1103,6 +1103,7 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; } + let runtime_workspace_roots_explicit = config.workspace_roots_explicit; let environments = environments.unwrap_or_else(|| { listener_task_context .thread_manager @@ -1247,6 +1248,10 @@ impl ThreadRequestProcessor { "could not render thread cwd `{cwd_uri}` using {cwd_convention}: {err}" )) })?; + let runtime_workspace_roots = thread_response_runtime_workspace_roots( + &config_snapshot, + runtime_workspace_roots_explicit, + )?; let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -1256,7 +1261,7 @@ impl ThreadRequestProcessor { model_provider: config_snapshot.model_provider_id, service_tier: config_snapshot.service_tier, cwd, - runtime_workspace_roots: config_snapshot.workspace_roots, + runtime_workspace_roots, instruction_sources, approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), diff --git a/codex-rs/app-server/src/request_processors/thread_response_paths.rs b/codex-rs/app-server/src/request_processors/thread_response_paths.rs new file mode 100644 index 0000000000..eb3e04a7c5 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_response_paths.rs @@ -0,0 +1,47 @@ +use crate::error_code::internal_error; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_core::ThreadConfigSnapshot; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; + +pub(super) fn thread_response_runtime_workspace_roots( + config_snapshot: &ThreadConfigSnapshot, + runtime_workspace_roots_explicit: bool, +) -> Result, JSONRPCErrorError> { + let selected_environment_cwd = config_snapshot + .environment_selections() + .first() + .map(|environment| &environment.cwd); + + config_snapshot + .workspace_roots + .iter() + .map(|root| { + if !runtime_workspace_roots_explicit + && root == config_snapshot.cwd() + && let Some(environment_cwd) = selected_environment_cwd + { + let convention = environment_cwd.infer_path_convention().ok_or_else(|| { + internal_error(format!( + "could not infer the path convention for runtime workspace root `{environment_cwd}`" + )) + })?; + return render_path_uri(environment_cwd, convention); + } + + render_path_uri(&PathUri::from_abs_path(root), PathConvention::native()) + }) + .collect() +} + +fn render_path_uri( + path: &PathUri, + convention: PathConvention, +) -> Result { + ApiPathString::from_path_uri(path, convention).map_err(|err| { + internal_error(format!( + "could not render runtime workspace root `{path}` using {convention}: {err}" + )) + }) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 75a093850a..dad13599ba 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -233,7 +233,58 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { } = to_response::(resp)?; assert_eq!(response_cwd.as_str(), cwd.to_string_lossy()); - assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); + assert_eq!( + runtime_workspace_roots, + vec![ApiPathString::from_abs_path( + &extra_root.abs(), + PathConvention::native(), + )?] + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_preserves_explicit_root_equal_to_host_fallback_with_selected_environment() +-> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let fallback_cwd = TempDir::new()?; + let environment_cwd = TempDir::new()?; + let explicit_root = fallback_cwd.path().to_path_buf().abs(); + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(fallback_cwd.path().display().to_string()), + runtime_workspace_roots: Some(vec![explicit_root.clone()]), + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: ApiPathString::from_abs_path( + &environment_cwd.path().to_path_buf().abs(), + PathConvention::native(), + )?, + }]), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadStartResponse = to_response(response)?; + + assert_eq!( + response.runtime_workspace_roots, + vec![ApiPathString::from_abs_path( + &explicit_root, + PathConvention::native(), + )?] + ); Ok(()) } @@ -273,7 +324,10 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro assert_eq!( runtime_workspace_roots, - vec![cwd.path().to_path_buf().abs()] + vec![ApiPathString::from_abs_path( + &cwd.path().to_path_buf().abs(), + PathConvention::native(), + )?] ); Ok(()) diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index a68ee42486..c00db86b3b 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -2,7 +2,6 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::PathBufExt; use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence; use app_test_support::to_response; @@ -189,6 +188,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions"; const CALL_ID: &str = "wine-cmd-smoke"; const COMMAND: &str = "Get-Content AGENTS.md -ErrorAction Stop"; + const NATIVE_CWD: &str = r"C:\windows"; WineExecServer .scope(|exec_server_url| async move { @@ -255,7 +255,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul .send_thread_start_request(ThreadStartParams { environments: Some(vec![TurnEnvironmentParams { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: serde_json::from_value::(json!(r"C:\windows"))?, + cwd: serde_json::from_value::(json!(NATIVE_CWD))?, }]), ..Default::default() }) @@ -267,10 +267,8 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul .await??; let response: ThreadStartResponse = to_response(response)?; assert!(!response.thread.id.is_empty()); - let host_cwd = codex_home.path().to_path_buf().abs(); assert_eq!(response.cwd.as_str(), NATIVE_CWD); - // TODO(anp): Derive runtime workspace roots from the selected remote environment. - assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); + assert_eq!(response.runtime_workspace_roots, vec![response.cwd.clone()]); // TODO(anp): Discover and report instruction sources from the remote filesystem. assert_eq!(response.instruction_sources, Vec::new()); // TODO(anp): Report the implicit built-in permission profile instead of None. diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index eecdf73cf0..0232c8670c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1540,18 +1540,14 @@ async fn thread_session_state_from_thread_start_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - let cwd_convention = response - .cwd - .infer_absolute_path_convention() - .ok_or_else(|| format!("thread cwd `{}` is not absolute", response.cwd.as_str()))?; - let cwd_uri = response - .cwd - .to_path_uri(cwd_convention) + let cwd = api_path_to_native_absolute_path(&response.cwd) .map_err(|err| format!("thread cwd is invalid: {err}"))?; - let cwd = ApiPathString::from_path_uri(&cwd_uri, PathConvention::native()) - .map_err(|err| format!("thread cwd cannot be represented on this host: {err}"))?; - let cwd = AbsolutePathBuf::try_from(cwd.into_string()) - .map_err(|err| format!("thread cwd is invalid on this host: {err}"))?; + let runtime_workspace_roots = response + .runtime_workspace_roots + .iter() + .map(api_path_to_native_runtime_workspace_root) + .collect::, _>>() + .map_err(|err| format!("runtime workspace root is invalid: {err}"))?; let permission_profile = display_permission_profile_from_thread_response( &response.sandbox, cwd.as_path(), @@ -1571,7 +1567,7 @@ async fn thread_session_state_from_thread_start_response( permission_profile, response.active_permission_profile.clone().map(Into::into), cwd, - response.runtime_workspace_roots.clone(), + runtime_workspace_roots, response.instruction_sources.clone(), response.reasoning_effort.clone(), config, @@ -1579,6 +1575,39 @@ async fn thread_session_state_from_thread_start_response( .await } +fn api_path_to_native_absolute_path(path: &ApiPathString) -> Result { + let convention = path + .infer_absolute_path_convention() + .ok_or_else(|| format!("path `{}` is not absolute", path.as_str()))?; + let path_uri = path + .to_path_uri(convention) + .map_err(|err| format!("path `{}` is invalid: {err}", path.as_str()))?; + let native_path = + ApiPathString::from_path_uri(&path_uri, PathConvention::native()).map_err(|err| { + format!( + "path `{}` cannot be represented on this host: {err}", + path.as_str() + ) + })?; + AbsolutePathBuf::try_from(native_path.into_string()) + .map_err(|err| format!("path `{}` is invalid on this host: {err}", path.as_str())) +} + +fn api_path_to_native_runtime_workspace_root( + path: &ApiPathString, +) -> Result { + let convention = path + .infer_absolute_path_convention() + .ok_or_else(|| format!("path `{}` is not absolute", path.as_str()))?; + if convention != PathConvention::native() { + return Err(format!( + "foreign path `{}` cannot be installed in the TUI's host-native config", + path.as_str() + )); + } + api_path_to_native_absolute_path(path) +} + async fn thread_session_state_from_thread_resume_response( response: &ThreadResumeResponse, config: &Config, @@ -1781,6 +1810,23 @@ mod tests { .expect("config should build") } + #[test] + fn foreign_runtime_workspace_root_is_rejected_instead_of_projected_onto_the_tui_host() { + let (path, convention) = match PathConvention::native() { + PathConvention::Posix => (r"C:\workspace", PathConvention::Windows), + PathConvention::Windows => ("/workspace", PathConvention::Posix), + }; + let root = ApiPathString::from_native_absolute_path(path, convention) + .expect("foreign path should be valid"); + + assert_eq!( + api_path_to_native_runtime_workspace_root(&root), + Err(format!( + "foreign path `{path}` cannot be installed in the TUI's host-native config" + )) + ); + } + fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot { RateLimitSnapshot { limit_id: Some(limit_id.to_string()),