From 014742db1f0a687a98ae4fc8f57264bd57186116 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 8 Jul 2026 14:48:44 -0700 Subject: [PATCH] core: move workspace roots onto environments --- .../schema/json/ClientRequest.json | 10 ++ .../codex_app_server_protocol.schemas.json | 10 ++ .../codex_app_server_protocol.v2.schemas.json | 10 ++ .../schema/json/v2/ThreadStartParams.json | 10 ++ .../schema/json/v2/TurnStartParams.json | 10 ++ .../typescript/v2/TurnEnvironmentParams.ts | 7 +- .../src/protocol/v2/tests.rs | 8 +- .../src/protocol/v2/turn.rs | 4 + codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/request_processors.rs | 27 ++- .../request_processors/thread_processor.rs | 15 +- .../src/request_processors/turn_processor.rs | 75 ++++---- .../tests/common/test_app_server.rs | 1 + .../app-server/tests/suite/v2/mcp_tool.rs | 1 + .../suite/v2/selected_capability_stack.rs | 3 + .../app-server/tests/suite/v2/thread_start.rs | 2 + .../app-server/tests/suite/v2/turn_start.rs | 30 +++- codex-rs/core/src/codex_thread.rs | 3 - codex-rs/core/src/environment_selection.rs | 31 +++- codex-rs/core/src/session/handlers.rs | 2 - codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/session.rs | 42 ++--- codex-rs/core/src/session/tests.rs | 166 ++---------------- codex-rs/core/src/session/turn_context.rs | 69 +++++--- codex-rs/core/src/thread_manager.rs | 31 +++- codex-rs/core/src/thread_manager_tests.rs | 1 + .../core/src/tools/handlers/apply_patch.rs | 9 +- .../src/tools/handlers/extension_tools.rs | 2 +- .../core/src/tools/handlers/view_image.rs | 6 +- codex-rs/core/src/tools/orchestrator.rs | 28 ++- .../core/src/tools/runtimes/apply_patch.rs | 10 +- .../src/tools/runtimes/apply_patch_tests.rs | 4 +- codex-rs/core/src/tools/runtimes/mod_tests.rs | 2 +- codex-rs/core/src/tools/runtimes/shell.rs | 4 + .../core/src/tools/runtimes/unified_exec.rs | 4 + codex-rs/core/src/tools/sandboxing.rs | 28 +-- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/core/tests/common/test_codex.rs | 5 +- .../remote_env_windows_test.rs | 1 + codex-rs/core/tests/suite/agents_md.rs | 2 + codex-rs/core/tests/suite/apply_patch_cli.rs | 1 + codex-rs/core/tests/suite/network_approval.rs | 1 + codex-rs/core/tests/suite/remote_env.rs | 6 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/ext/skills/tests/skills_extension.rs | 2 + codex-rs/memories/write/src/runtime.rs | 2 +- codex-rs/protocol/src/protocol.rs | 5 +- 47 files changed, 390 insertions(+), 308 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5da27a074c..06b260bfb4 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4496,6 +4496,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ 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 a0f893b7f8..83ad817f17 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 @@ -20170,6 +20170,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/v2/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ 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 0fe34feff3..00dda383cb 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 @@ -17949,6 +17949,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 3819f1f761..f1ad30099e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -281,6 +281,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cc4869e1d1..6b55e4c8bb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -362,6 +362,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted inherits the request's top-level `runtimeWorkspaceRoots` fallback.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts index cb93ba3964..2d7131a331 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -3,4 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { LegacyAppPathString } from "../LegacyAppPathString"; -export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, }; +export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, +/** + * Environment-native runtime workspace roots. Omitted inherits the + * request's top-level `runtimeWorkspaceRoots` fallback. + */ +runtimeWorkspaceRoots?: Array | null, }; 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 d35d39fe46..a57eb1b227 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -4217,13 +4217,15 @@ fn turn_start_params_round_trip_environments() { let raw_cwd = r"C:\workspace"; let cwd: LegacyAppPathString = serde_json::from_value(json!(raw_cwd)).expect("API path should deserialize"); + let workspace_root = cwd.clone(); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], "environments": [ { "environmentId": "local", - "cwd": cwd + "cwd": cwd, + "runtimeWorkspaceRoots": [workspace_root] } ], })) @@ -4234,6 +4236,7 @@ fn turn_start_params_round_trip_environments() { Some(vec![TurnEnvironmentParams { environment_id: "local".to_string(), cwd: cwd.clone(), + runtime_workspace_roots: Some(vec![workspace_root.clone()]), }]) ); assert_eq!( @@ -4247,7 +4250,8 @@ fn turn_start_params_round_trip_environments() { Some(&json!([ { "environmentId": "local", - "cwd": cwd + "cwd": cwd, + "runtimeWorkspaceRoots": [workspace_root] } ])) ); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index af99b4b2e7..5c0e354e43 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -41,6 +41,10 @@ pub enum TurnStatus { pub struct TurnEnvironmentParams { pub environment_id: String, pub cwd: LegacyAppPathString, + /// Environment-native runtime workspace roots. Omitted inherits the + /// request's top-level `runtimeWorkspaceRoots` fallback. + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6280cf5204..037291bf0d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -137,7 +137,7 @@ Example with notification opt-out: ## API Overview -- `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 `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. 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`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. 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 using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client. +- `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 `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` supplies the default 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`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. 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` and optional environment-native `runtimeWorkspaceRoots`. Omitted per-environment roots inherit the top-level fallback; an empty list explicitly selects no roots for that environment. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client. - `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; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` is rejected. If `lastTurnId` is null while the source thread is 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. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. 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. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. @@ -168,7 +168,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids. - `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated. - `thread/rollback` — deprecated and will be removed soon. Drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` supplies the default roots for newly resolved environment selections. Explicit `environments[].runtimeWorkspaceRoots` override that fallback with environment-native absolute paths. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index da88d0e067..6b30cc055b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -368,7 +368,6 @@ use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginS use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; -use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; use codex_features::Feature; @@ -465,6 +464,7 @@ use codex_thread_store::ThreadSortKey as StoreThreadSortKey; use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::BTreeMap; use std::collections::HashMap; @@ -564,6 +564,7 @@ fn resolve_request_cwd(cwd: Option) -> Result, fn resolve_turn_environment_selections( thread_manager: &ThreadManager, environments: Option>, + fallback_workspace_roots: &[AbsolutePathBuf], ) -> Result>, JSONRPCErrorError> { let Some(environments) = environments else { return Ok(None); @@ -580,9 +581,33 @@ fn resolve_turn_environment_selections( environment.cwd )) })?; + let workspace_roots = environment + .runtime_workspace_roots + .map(|roots| { + let mut resolved_roots = Vec::new(); + for root in roots { + let root = root.to_inferred_path_uri().ok_or_else(|| { + invalid_request(format!( + "invalid runtime workspace root for environment `{environment_id}`: path `{root}` does not use absolute POSIX or Windows path syntax" + )) + })?; + if !resolved_roots.contains(&root) { + resolved_roots.push(root); + } + } + Ok::<_, JSONRPCErrorError>(resolved_roots) + }) + .transpose()? + .unwrap_or_else(|| { + fallback_workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect() + }); selections.push(TurnEnvironmentSelection { environment_id, cwd, + workspace_roots, }); } thread_manager 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 fc6a3281b4..cdaf640075 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -968,8 +968,6 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } - let environment_selections = - resolve_turn_environment_selections(self.thread_manager.as_ref(), environments)?; let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, @@ -1017,7 +1015,7 @@ impl ThreadRequestProcessor { history_mode.map(Into::into), session_start_source, thread_source.map(Into::into), - environment_selections, + environments, service_name, allow_provider_model_fallback, experimental_raw_events, @@ -1094,7 +1092,7 @@ impl ThreadRequestProcessor { history_mode: Option, session_start_source: Option, thread_source: Option, - environments: Option>, + environments: Option>, service_name: Option, allow_provider_model_fallback: bool, experimental_raw_events: bool, @@ -1188,10 +1186,15 @@ impl ThreadRequestProcessor { } } - let environments = environments.unwrap_or_else(|| { + let environments = resolve_turn_environment_selections( + listener_task_context.thread_manager.as_ref(), + environments, + &config.workspace_roots, + )? + .unwrap_or_else(|| { listener_task_context .thread_manager - .default_environment_selections(&config.cwd) + .default_environment_selections(&config.cwd, &config.workspace_roots) }); let dynamic_tools = dynamic_tools.unwrap_or_default(); if !dynamic_tools.is_empty() { 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 3ff64cd34e..547d628728 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -481,8 +481,18 @@ impl TurnRequestProcessor { )) })?; - let environment_selections = - resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; + let runtime_workspace_roots = params + .runtime_workspace_roots + .map(resolve_runtime_workspace_roots); + let fallback_workspace_roots = match runtime_workspace_roots.as_ref() { + Some(workspace_roots) => workspace_roots.clone(), + None => thread.config_snapshot().await.workspace_roots, + }; + let environment_selections = resolve_turn_environment_selections( + self.thread_manager.as_ref(), + params.environments, + &fallback_workspace_roots, + )?; // Map v2 input items to core input items. let mapped_items: Vec = params @@ -495,7 +505,12 @@ impl TurnRequestProcessor { let turn_has_input = !mapped_items.is_empty(); let cwd = resolve_request_cwd(params.cwd)?; let environments = self - .build_environment_override(thread.as_ref(), cwd, environment_selections) + .build_environment_override( + thread.as_ref(), + cwd, + runtime_workspace_roots.clone(), + environment_selections, + ) .await; let thread_settings = self .build_thread_settings_overrides( @@ -503,7 +518,7 @@ impl TurnRequestProcessor { ThreadSettingsBuildParams { method: "turn/start", environments, - runtime_workspace_roots: params.runtime_workspace_roots, + runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, @@ -572,33 +587,25 @@ impl TurnRequestProcessor { &self, thread: &CodexThread, cwd: Option, + workspace_roots: Option>, environment_selections: Option>, ) -> Option { - match (cwd, environment_selections) { - (None, None) => None, - (Some(cwd), None) => { - let environment_selections = - self.thread_manager.default_environment_selections(&cwd); - Some(TurnEnvironmentSelections::new(cwd, environment_selections)) - } - (cwd, Some(environment_selections)) => { - let legacy_fallback_cwd = match cwd { - Some(cwd) => cwd, - None => { - let snapshot = thread.config_snapshot().await; - environment_selections - .iter() - .find(|selection| selection.environment_id == LOCAL_ENVIRONMENT_ID) - .and_then(|selection| selection.cwd.to_abs_path().ok()) - .unwrap_or_else(|| snapshot.cwd().clone()) - } - }; - Some(TurnEnvironmentSelections::new( - legacy_fallback_cwd, - environment_selections, - )) - } + if cwd.is_none() && workspace_roots.is_none() && environment_selections.is_none() { + return None; } + let snapshot = thread.config_snapshot().await; + let legacy_fallback_cwd = cwd.unwrap_or_else(|| snapshot.cwd().clone()); + let legacy_fallback_workspace_roots = workspace_roots.unwrap_or(snapshot.workspace_roots); + let environment_selections = environment_selections.unwrap_or_else(|| { + self.thread_manager.default_environment_selections( + &legacy_fallback_cwd, + &legacy_fallback_workspace_roots, + ) + }); + Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )) } async fn build_thread_settings_overrides( @@ -654,8 +661,7 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = - runtime_workspace_roots_request.map(resolve_runtime_workspace_roots); + let runtime_workspace_roots = runtime_workspace_roots_request; let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -715,7 +721,6 @@ impl TurnRequestProcessor { thread .preview_thread_settings_overrides(CodexThreadSettingsOverrides { environments: environments.clone(), - workspace_roots: runtime_workspace_roots.clone(), approval_policy, approvals_reviewer, sandbox_policy: sandbox_policy.clone(), @@ -738,7 +743,6 @@ impl TurnRequestProcessor { Ok(codex_protocol::protocol::ThreadSettingsOverrides { environments, - workspace_roots: runtime_workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -763,7 +767,12 @@ impl TurnRequestProcessor { let (_, thread) = self.load_thread(¶ms.thread_id).await?; let cwd = resolve_request_cwd(params.cwd)?; let environments = self - .build_environment_override(thread.as_ref(), cwd, /*environment_selections*/ None) + .build_environment_override( + thread.as_ref(), + cwd, + /*workspace_roots*/ None, + /*environment_selections*/ None, + ) .await; let thread_settings = self .build_thread_settings_overrides( diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 825ea46ab8..9352b05a18 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -192,6 +192,7 @@ impl TestAppServer { Ok(TurnEnvironmentParams { environment_id: selection.environment_id.clone(), cwd: selection.cwd.clone().into(), + runtime_workspace_roots: None, }) } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index ff8d0fcacd..453b4c0abf 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -381,6 +381,7 @@ url = "{mcp_server_url}/mcp" capability_root.path().to_path_buf(), )? .into(), + runtime_workspace_roots: None, }]), selected_capability_roots: Some(vec![SelectedCapabilityRoot { id: "late-plugin@1".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index 9e8983840b..20ec96481d 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -366,6 +366,7 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> environments: Some(vec![TurnEnvironmentParams { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: fixture.environment_cwd.into(), + runtime_workspace_roots: None, }]), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Plan, @@ -644,6 +645,7 @@ async fn start_thread( environments: Some(vec![TurnEnvironmentParams { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: environment_cwd.into(), + runtime_workspace_roots: None, }]), selected_capability_roots: Some(vec![selected_root]), ..Default::default() @@ -674,6 +676,7 @@ async fn run_turn( environments: Some(vec![TurnEnvironmentParams { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: environment_cwd.into(), + runtime_workspace_roots: None, }]), ..Default::default() }) 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 363b1c3389..259223cae6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -619,6 +619,7 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result codex_home.path().to_path_buf(), )? .into(), + runtime_workspace_roots: None, }]), ..Default::default() }) @@ -655,6 +656,7 @@ async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> R environments: Some(vec![TurnEnvironmentParams { environment_id: environment_id.clone(), cwd: serde_json::from_value(json!("relative"))?, + runtime_workspace_roots: None, }]), ..Default::default() }) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index c748c413c2..637224d5f9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1378,6 +1378,7 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result codex_home.path().to_path_buf(), )? .into(), + runtime_workspace_roots: None, }]), ..Default::default() }) @@ -2711,8 +2712,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { #[cfg(unix)] #[tokio::test] -async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_turns() -> Result<()> -{ +async fn turn_environment_workspace_roots_override_and_inherit_turn_fallback() -> Result<()> { skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -2720,12 +2720,19 @@ async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_t std::fs::create_dir(&codex_home)?; let old_root = tmp.path().join("old-root"); let new_root = tmp.path().join("new-root"); + let fallback_root = tmp.path().join("fallback-root"); std::fs::create_dir(&old_root)?; std::fs::create_dir(&new_root)?; + std::fs::create_dir(&fallback_root)?; let old_root_text = old_root.to_string_lossy().into_owned(); let new_root_text = new_root.to_string_lossy().into_owned(); + let fallback_root_text = fallback_root.to_string_lossy().into_owned(); let old_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(old_root)?; let new_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(new_root)?; + let fallback_root = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(fallback_root)?; + let environment_cwd = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(tmp.path())?; let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( @@ -2794,7 +2801,12 @@ stream_max_retries = 0 text: "select dev profile".to_string(), text_elements: Vec::new(), }], - runtime_workspace_roots: Some(vec![old_root]), + runtime_workspace_roots: Some(vec![fallback_root]), + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: environment_cwd.clone().into(), + runtime_workspace_roots: Some(vec![old_root.into()]), + }]), permissions: Some("dev".to_string()), ..Default::default() }) @@ -2819,6 +2831,11 @@ stream_max_retries = 0 text_elements: Vec::new(), }], runtime_workspace_roots: Some(vec![new_root]), + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: environment_cwd.into(), + runtime_workspace_roots: None, + }]), ..Default::default() }) .await?; @@ -2848,15 +2865,15 @@ stream_max_retries = 0 let first_permissions = latest_permissions_instructions(&requests[0]); assert!(first_permissions.contains(&old_root_text)); assert!( - !first_permissions.contains(&new_root_text), - "first turn should materialize the initial runtime workspace root" + !first_permissions.contains(&fallback_root_text), + "environment roots should override the turn-level fallback" ); let second_permissions = latest_permissions_instructions(&requests[1]); assert!(second_permissions.contains(&new_root_text)); assert!( !second_permissions.contains(&old_root_text), - "second turn should rebind :workspace_roots to the updated runtime workspace root" + "omitted environment roots should inherit the updated turn-level fallback" ); Ok(()) @@ -3010,6 +3027,7 @@ fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option, - pub workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, @@ -369,7 +368,6 @@ impl CodexThread { ) -> SessionSettingsUpdate { let CodexThreadSettingsOverrides { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -396,7 +394,6 @@ impl CodexThread { SessionSettingsUpdate { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index ef6a3ec5ea..bed131a5cf 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -22,6 +22,7 @@ use crate::shell_snapshot::ShellSnapshot; pub(crate) fn default_thread_environment_selections( environment_manager: &EnvironmentManager, cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], ) -> Vec { environment_manager .default_environment_ids() @@ -29,6 +30,7 @@ pub(crate) fn default_thread_environment_selections( .map(|environment_id| TurnEnvironmentSelection { environment_id, cwd: PathUri::from_abs_path(cwd), + workspace_roots: workspace_roots.iter().map(PathUri::from_abs_path).collect(), }) .collect() } @@ -176,7 +178,8 @@ impl ThreadEnvironments { Some(local_shell) }; let mut turn_environment = - TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell); + TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell) + .with_workspace_roots(selection.workspace_roots); let task = shell_snapshot .build(turn_environment.clone()) .boxed() @@ -406,10 +409,11 @@ mod tests { .await; assert_eq!( - default_thread_environment_selections(&manager, &cwd), + default_thread_environment_selections(&manager, &cwd, std::slice::from_ref(&cwd)), vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: cwd_uri, + cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri], }] ); } @@ -434,15 +438,17 @@ url = "ws://127.0.0.1:8765" .expect("environment manager"); assert_eq!( - default_thread_environment_selections(&manager, &cwd), + default_thread_environment_selections(&manager, &cwd, std::slice::from_ref(&cwd)), vec![ TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri.clone()], }, TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: cwd_uri, + cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri], }, ] ); @@ -454,7 +460,7 @@ url = "ws://127.0.0.1:8765" let manager = EnvironmentManager::without_environments(); assert_eq!( - default_thread_environment_selections(&manager, &cwd), + default_thread_environment_selections(&manager, &cwd, std::slice::from_ref(&cwd)), Vec::::new() ); } @@ -476,6 +482,7 @@ url = "ws://127.0.0.1:8765" turn_environments.update_selections(&[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: Vec::new(), }]); let snapshot = turn_environments.snapshot().await; @@ -496,6 +503,7 @@ url = "ws://127.0.0.1:8765" let first = TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: Vec::new(), }; let resolved = resolve_turn_environments( @@ -505,6 +513,7 @@ url = "ws://127.0.0.1:8765" TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.join("other").expect("other cwd URI"), + workspace_roots: Vec::new(), }, ], ) @@ -525,6 +534,7 @@ url = "ws://127.0.0.1:8765" &[TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: selected_cwd_uri, + workspace_roots: Vec::new(), }], ) .await; @@ -562,6 +572,7 @@ url = "ws://127.0.0.1:8765" let local = TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: Vec::new(), }; let resolved = resolve_turn_environments( @@ -570,6 +581,7 @@ url = "ws://127.0.0.1:8765" TurnEnvironmentSelection { environment_id: "missing".to_string(), cwd: cwd_uri, + workspace_roots: Vec::new(), }, local.clone(), ], @@ -597,6 +609,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + workspace_roots: Vec::new(), }; let environments = Arc::new(ThreadEnvironments::new( manager, @@ -644,10 +657,12 @@ url = "ws://127.0.0.1:8765" let remote = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: cwd.clone(), + workspace_roots: Vec::new(), }; let local = TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd, + workspace_roots: Vec::new(), }; let turn_environments = ThreadEnvironments::new( manager, @@ -706,6 +721,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + workspace_roots: Vec::new(), }; let environments = ThreadEnvironments::new( Arc::clone(&manager), @@ -757,6 +773,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: Vec::new(), }; let environments = ThreadEnvironments::new( Arc::clone(&manager), @@ -813,6 +830,7 @@ url = "ws://127.0.0.1:8765" let selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: Vec::new(), }; let inherited_environment = Arc::new( Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) @@ -865,6 +883,7 @@ url = "ws://127.0.0.1:8765" &[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: Vec::new(), }], ) .await; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index cc21755524..552c03803d 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -122,7 +122,6 @@ async fn thread_settings_update( ) -> SessionSettingsUpdate { let ThreadSettingsOverrides { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -151,7 +150,6 @@ async fn thread_settings_update( }; SessionSettingsUpdate { environments, - workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9339413b33..d2d681d22e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -658,7 +658,6 @@ impl Codex { config.cwd.clone(), environment_selections, ), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7c59ec22b7..0734f3090a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -80,9 +80,6 @@ pub(crate) struct SessionConfiguration { /// Sticky thread-level environment selections plus the legacy cwd used /// when a turn does not select an environment. pub(super) environments: TurnEnvironmentSelections, - /// Thread-scoped runtime workspace roots for materializing symbolic - /// workspace permissions at session runtime. - pub(super) workspace_roots: Vec, /// Directory containing all Codex state for this session. pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -119,6 +116,21 @@ impl SessionConfiguration { &self.environments.environments } + pub(super) fn workspace_roots(&self) -> Vec { + self.environments + .environments + .first() + .and_then(|environment| { + environment + .workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>() + .ok() + }) + .unwrap_or_else(|| vec![self.cwd().clone()]) + } + pub(crate) fn codex_home(&self) -> &AbsolutePathBuf { &self.codex_home } @@ -131,7 +143,7 @@ impl SessionConfiguration { self.permission_profile_state .permission_profile() .clone() - .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + .materialize_project_roots_with_workspace_roots(&self.workspace_roots()) } pub(super) fn active_permission_profile(&self) -> Option { @@ -186,7 +198,7 @@ impl SessionConfiguration { permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), environments: self.environments.clone(), - workspace_roots: self.workspace_roots.clone(), + workspace_roots: self.workspace_roots(), profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), @@ -265,25 +277,8 @@ impl SessionConfiguration { .environments .clone() .unwrap_or_else(|| self.environments.clone()); - let cwd_changed = next_environments.legacy_fallback_cwd.as_path() != current_cwd.as_path(); + let cwd_changed = next_environments.legacy_fallback_cwd != current_cwd; next_configuration.environments = next_environments; - if let Some(workspace_roots) = updates.workspace_roots.clone() { - next_configuration.workspace_roots = workspace_roots; - } else if cwd_changed && self.workspace_roots.contains(¤t_cwd) { - let mut retargeted_workspace_roots = - Vec::with_capacity(next_configuration.workspace_roots.len()); - for root in &self.workspace_roots { - let root = if root == ¤t_cwd { - next_configuration.cwd().clone() - } else { - root.clone() - }; - if !retargeted_workspace_roots.contains(&root) { - retargeted_workspace_roots.push(root); - } - } - next_configuration.workspace_roots = retargeted_workspace_roots; - } if let Some(permission_profile) = updates.permission_profile.clone() { let active_permission_profile = @@ -416,7 +411,6 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { pub(crate) environments: Option, - pub(crate) workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, pub(crate) approvals_reviewer: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 696a8adbd8..7cbea472fb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -49,9 +49,7 @@ use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; -use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::AgentMessageInputContent; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; @@ -181,6 +179,7 @@ use tokio::sync::Semaphore; use tokio::time::sleep; use tokio::time::timeout; use tracing_opentelemetry::OpenTelemetrySpanExt; + use uuid::Uuid; use codex_protocol::mcp::CallToolResult as McpCallToolResult; @@ -2919,52 +2918,6 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow::Result<()> { - let codex_home = tempfile::TempDir::new()?; - let cwd = tempfile::TempDir::new()?; - let old_root = test_path_buf("/workspace/old").abs(); - let new_root = test_path_buf("/workspace/new").abs(); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(crate::config::ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), - additional_writable_roots: vec![old_root.to_path_buf()], - ..Default::default() - }) - .build() - .await?; - - let session_permission_profile_state = session_permission_profile_state_from_config(&config)?; - let stored_file_system_policy = session_permission_profile_state - .permission_profile() - .file_system_sandbox_policy(); - assert!( - !stored_file_system_policy - .can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path()), - "session permission profile state should keep runtime workspace roots symbolic" - ); - - let mut session_configuration = make_session_configuration_for_tests().await; - session_configuration.environments = - TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()); - session_configuration.workspace_roots = config.workspace_roots.clone(); - session_configuration.permission_profile_state = session_permission_profile_state; - - let initial_policy = session_configuration.file_system_sandbox_policy(); - assert!(initial_policy.can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path())); - - let updated = session_configuration.apply(&SessionSettingsUpdate { - workspace_roots: Some(vec![new_root.clone()]), - ..Default::default() - })?; - let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -3746,7 +3699,6 @@ async fn set_rate_limits_retains_previous_credits() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -3853,7 +3805,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -4390,7 +4341,6 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -4548,7 +4498,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd ) .expect("set permission profile"); let expected_file_system_sandbox_policy = file_system_sandbox_policy - .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots()); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -4610,7 +4560,7 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ .expect("permission profile update should succeed"); let mut expected_file_system_policy = requested_file_system_policy - .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots()); expected_file_system_policy.glob_scan_max_depth = Some(2); expected_file_system_policy.entries.push(deny_entry); assert_eq!( @@ -4666,94 +4616,6 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo ); } -#[tokio::test] -async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspace_roots() { - let mut session_configuration = make_session_configuration_for_tests().await; - let old_root = tempfile::tempdir().expect("create old root"); - let new_root = tempfile::tempdir().expect("create new root"); - let profile_root = tempfile::tempdir().expect("create profile root"); - let old_root = old_root.path().abs(); - let new_root = new_root.path().abs(); - let profile_root = profile_root.path().abs(); - session_configuration.workspace_roots = vec![old_root.clone()]; - - let file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Write, - }]); - let permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ); - - let updated = session_configuration - .apply(&SessionSettingsUpdate { - workspace_roots: Some(vec![new_root.clone()]), - permission_profile: Some(permission_profile), - active_permission_profile: Some(ActivePermissionProfile::new("dev")), - profile_workspace_roots: Some(vec![profile_root.clone()]), - ..Default::default() - }) - .expect("permission profile update should succeed"); - - let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); - assert_eq!( - updated.active_permission_profile(), - Some(ActivePermissionProfile::new("dev")) - ); - assert_eq!(updated.profile_workspace_roots(), &[profile_root]); -} - -#[tokio::test] -async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_update() { - let mut session_configuration = make_session_configuration_for_tests().await; - let old_root = tempfile::tempdir().expect("create old root"); - let new_root = tempfile::tempdir().expect("create new root"); - let extra_root = tempfile::tempdir().expect("create extra root"); - let old_root = old_root.path().abs(); - let new_root = new_root.path().abs(); - let extra_root = extra_root.path().abs(); - session_configuration.environments = - TurnEnvironmentSelections::new(old_root.clone(), Vec::new()); - session_configuration.workspace_roots = vec![old_root.clone(), extra_root.clone()]; - - let file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Write, - }]); - let permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ); - session_configuration - .set_permission_profile_for_tests(permission_profile) - .expect("set permission profile"); - - let updated = session_configuration - .apply(&SessionSettingsUpdate { - environments: Some(TurnEnvironmentSelections::new(new_root.clone(), Vec::new())), - ..Default::default() - }) - .expect("cwd-only update should succeed"); - - assert_eq!( - updated.workspace_roots, - vec![new_root.clone(), extra_root.clone()] - ); - let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); - assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd().as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); -} - #[tokio::test] async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Result<()> { let codex_home = tempfile::tempdir().expect("create codex home"); @@ -4963,7 +4825,6 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let project_root = workspace.path().join("repo-b").abs(); session_configuration.environments = TurnEnvironmentSelections::new(original_cwd.clone(), Vec::new()); - session_configuration.workspace_roots = vec![session_configuration.cwd().clone()]; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -4994,7 +4855,7 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda }) .expect("cwd-only update should succeed"); - assert_eq!(updated.workspace_roots, vec![project_root.clone()]); + assert_eq!(updated.workspace_roots(), vec![project_root.clone()]); assert!( updated .file_system_sandbox_policy() @@ -5261,7 +5122,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -5393,7 +5253,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -5409,8 +5268,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { dynamic_tools: Vec::new(), user_shell_override: None, }; - let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); + let per_turn_config = Session::build_per_turn_config( + &session_configuration, + session_configuration.cwd().clone(), + session_configuration.workspace_roots(), + ); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -5647,7 +5509,6 @@ async fn make_session_with_config_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -5755,7 +5616,6 @@ async fn make_session_with_history_source_and_agent_control_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -7525,7 +7385,6 @@ where permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), - workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -7541,8 +7400,11 @@ where dynamic_tools, user_shell_override: None, }; - let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); + let per_turn_config = Session::build_per_turn_config( + &session_configuration, + session_configuration.cwd().clone(), + session_configuration.workspace_roots(), + ); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e200413d3d..45ad4ebf5a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -45,6 +45,7 @@ pub(crate) struct TurnEnvironment { pub(crate) environment_id: String, pub(crate) environment: Arc, cwd: PathUri, + workspace_roots: Vec, pub(crate) shell: Option, pub(crate) shell_snapshot: ShellSnapshotTask, } @@ -60,6 +61,7 @@ impl TurnEnvironment { environment_id, environment, cwd, + workspace_roots: Vec::new(), shell, shell_snapshot: futures::future::ready(None).boxed().shared(), } @@ -79,10 +81,20 @@ impl TurnEnvironment { &self.cwd } + pub(crate) fn workspace_roots(&self) -> &[PathUri] { + &self.workspace_roots + } + + pub(crate) fn with_workspace_roots(mut self, workspace_roots: Vec) -> Self { + self.workspace_roots = workspace_roots; + self + } + pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), } } } @@ -93,6 +105,7 @@ impl std::fmt::Debug for TurnEnvironment { .field("environment_id", &self.environment_id) .field("environment", &self.environment) .field("cwd", &self.cwd) + .field("workspace_roots", &self.workspace_roots) .field("shell", &self.shell) .finish_non_exhaustive() } @@ -310,10 +323,11 @@ impl TurnContext { pub(crate) fn file_system_sandbox_context( &self, additional_permissions: Option, - cwd: &PathUri, + environment: &TurnEnvironment, ) -> FileSystemSandboxContext { + let permission_profile = self.config.permissions.permission_profile(); let (base_file_system_sandbox_policy, base_network_sandbox_policy) = - self.permission_profile.to_runtime_permissions(); + permission_profile.to_runtime_permissions(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( &base_file_system_sandbox_policy, additional_permissions.as_ref(), @@ -323,19 +337,14 @@ impl TurnContext { additional_permissions.as_ref(), ); let permissions = PermissionProfile::from_runtime_permissions_with_enforcement( - self.permission_profile.enforcement(), + permission_profile.enforcement(), &file_system_sandbox_policy, network_sandbox_policy, ); FileSystemSandboxContext { permissions: permissions.into(), - cwd: Some(cwd.clone()), - workspace_roots: self - .config - .effective_workspace_roots() - .iter() - .map(PathUri::from_abs_path) - .collect(), + cwd: Some(environment.cwd().clone()), + workspace_roots: environment.workspace_roots().to_vec(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config @@ -426,15 +435,16 @@ impl Session { pub(crate) fn build_per_turn_config( session_configuration: &SessionConfiguration, cwd: AbsolutePathBuf, + workspace_roots: Vec, ) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); per_turn_config.cwd = cwd; - per_turn_config.workspace_roots = session_configuration.workspace_roots.clone(); + per_turn_config.workspace_roots = workspace_roots.clone(); per_turn_config .permissions - .set_workspace_roots(session_configuration.workspace_roots.clone()); + .set_workspace_roots(workspace_roots); per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -465,14 +475,16 @@ impl Session { pub(crate) fn build_effective_session_config( session_configuration: &SessionConfiguration, ) -> Config { - let mut config = - Self::build_per_turn_config(session_configuration, session_configuration.cwd().clone()); + let workspace_roots = session_configuration.workspace_roots(); + let mut config = Self::build_per_turn_config( + session_configuration, + session_configuration.cwd().clone(), + workspace_roots.clone(), + ); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); - config.workspace_roots = session_configuration.workspace_roots.clone(); - config - .permissions - .set_workspace_roots(session_configuration.workspace_roots.clone()); + config.workspace_roots = workspace_roots.clone(); + config.permissions.set_workspace_roots(workspace_roots); config } @@ -523,6 +535,7 @@ impl Session { per_turn_config.features.enabled(Feature::FastMode), &model_info, ); + let permission_profile = per_turn_config.permissions.effective_permission_profile(); let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( session_id.to_string(), @@ -533,7 +546,7 @@ impl Session { session_configuration.thread_source.clone(), sub_id.clone(), cwd.clone(), - &session_configuration.permission_profile(), + &permission_profile, session_configuration.windows_sandbox_level, network.is_some(), )); @@ -566,7 +579,7 @@ impl Session { multi_agent_version, personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), - permission_profile: session_configuration.permission_profile(), + permission_profile, network, windows_sandbox_level: session_configuration.windows_sandbox_level, available_models, @@ -697,7 +710,21 @@ impl Session { .as_ref() .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); - let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); + let workspace_roots = primary_turn_environment + .as_ref() + .map(TurnEnvironment::workspace_roots) + .map(|workspace_roots| { + workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>() + }) + .transpose() + .ok() + .flatten() + .unwrap_or_else(|| session_configuration.workspace_roots()); + let per_turn_config = + Self::build_per_turn_config(&session_configuration, cwd.clone(), workspace_roots); { let mcp_runtime = self.services.latest_mcp_runtime(); let mcp_connection_manager = mcp_runtime.manager(); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index de37484f8b..508f8ffad7 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -509,8 +509,13 @@ impl ThreadManager { pub fn default_environment_selections( &self, cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], ) -> Vec { - default_thread_environment_selections(self.state.environment_manager.as_ref(), cwd) + default_thread_environment_selections( + self.state.environment_manager.as_ref(), + cwd, + workspace_roots, + ) } pub fn validate_environment_selections( @@ -658,6 +663,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); Box::pin(self.start_thread_with_options(StartThreadOptions { config, @@ -789,6 +795,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); let (session_source, thread_source) = initial_history .get_resumed_session_sources() @@ -827,6 +834,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); Box::pin(self.state.spawn_thread( config, @@ -860,6 +868,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); let (session_source, thread_source) = initial_history .get_resumed_session_sources() @@ -1048,6 +1057,7 @@ impl ThreadManager { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, + &config.workspace_roots, ); let agent_control = self.agent_control_for_config(&config); Box::pin(self.state.spawn_thread( @@ -1364,7 +1374,11 @@ impl ThreadManagerState { environments: Option>, ) -> CodexResult { let environments = environments.unwrap_or_else(|| { - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd) + default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + &config.workspace_roots, + ) }); Box::pin(self.spawn_thread_with_source( config, @@ -1403,8 +1417,11 @@ impl ThreadManagerState { inherited_environments, inherited_exec_policy, } = options; - let environments = - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); + let environments = default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + &config.workspace_roots, + ); let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.spawn_thread_with_source( config, @@ -1446,7 +1463,11 @@ impl ThreadManagerState { thread_extension_init: ExtensionDataInit, ) -> CodexResult { let environments = environments.unwrap_or_else(|| { - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd) + default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + &config.workspace_roots, + ) }); Box::pin(self.spawn_thread_with_source( config, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d29d6d04e5..b4842e2973 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -829,6 +829,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { let environments = vec![TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: PathUri::from_abs_path(&selected_cwd), + workspace_roots: Vec::new(), }]; let default_cwd = config.cwd.clone(); let mut source_config = config.clone(); diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 6326dbc1ad..0950174ba4 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -383,10 +383,8 @@ impl ApplyPatchHandler { )); }; let fs = turn_environment.environment.get_filesystem(); - let sandbox = turn.file_system_sandbox_context( - /*additional_permissions*/ None, - turn_environment.cwd(), - ); + let sandbox = turn + .file_system_sandbox_context(/*additional_permissions*/ None, turn_environment); match codex_apply_patch::verify_apply_patch_args( args, turn_environment.cwd(), @@ -554,7 +552,8 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); + let sandbox = + turn.file_system_sandbox_context(/*additional_permissions*/ None, &turn_environment); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 2fd998849e..ec4eb85e88 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -133,7 +133,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .additional_permissions; let file_system_sandbox_context = invocation .turn - .file_system_sandbox_context(additional_permissions, environment.cwd()); + .file_system_sandbox_context(additional_permissions, environment); environments.push(ToolEnvironment { environment_id: environment.environment_id.clone(), cwd: native_cwd, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 625b5b8713..c3ec34ed22 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -147,10 +147,8 @@ impl ViewImageHandler { )) })?; let model_visible_path = path_uri.inferred_native_path_string(); - let sandbox = turn.file_system_sandbox_context( - /*additional_permissions*/ None, - turn_environment.cwd(), - ); + let sandbox = turn + .file_system_sandbox_context(/*additional_permissions*/ None, turn_environment); let fs = turn_environment.environment.get_filesystem(); let metadata = fs diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index f82ad5bb70..475bc170a0 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -13,6 +13,7 @@ use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::network_approval_context_from_payload; +use crate::session::turn_context::TurnEnvironment; use crate::tools::flat_tool_name; use crate::tools::network_approval::ActiveNetworkApproval; use crate::tools::network_approval::DeferredNetworkApproval; @@ -265,16 +266,33 @@ impl ToolOrchestrator { .sandbox_cwd(req) .cloned() .unwrap_or_else(|| PathUri::from_abs_path(&turn_ctx.cwd)); - let workspace_roots = turn_ctx.config.effective_workspace_roots(); + let workspace_roots = tool + .turn_environment(req) + .or_else(|| turn_ctx.environments.primary()) + .map(TurnEnvironment::workspace_roots) + .unwrap_or_default(); + let permissions = workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>() + .map(|workspace_roots| { + turn_ctx + .config + .permissions + .permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots) + }) + .unwrap_or_else(|_| turn_ctx.permission_profile.clone()); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, sandbox_requested, - permissions: &turn_ctx.permission_profile, + permissions: &permissions, exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: workspace_roots.as_slice(), + workspace_roots, codex_linux_sandbox_exe: turn_ctx.config.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, @@ -452,12 +470,12 @@ impl ToolOrchestrator { let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, sandbox_requested: retry_sandbox_requested, - permissions: &turn_ctx.permission_profile, + permissions: &permissions, exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: workspace_roots.as_slice(), + workspace_roots, codex_linux_sandbox_exe: retry_codex_linux_sandbox_exe, use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index e5617e0a50..9617f9140a 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -105,11 +105,7 @@ impl ApplyPatchRuntime { Some(FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(attempt.sandbox_cwd.clone()), - workspace_roots: attempt - .workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect(), + workspace_roots: attempt.workspace_roots.to_vec(), windows_sandbox_level: attempt.windows_sandbox_level, windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop, use_legacy_landlock: attempt.use_legacy_landlock, @@ -223,6 +219,10 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { + fn turn_environment<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a TurnEnvironment> { + Some(&req.turn_environment) + } + fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a PathUri> { Some(&req.action.cwd) } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index c70002e62f..bcb474e961 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -226,7 +226,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: std::slice::from_ref(&path), + workspace_roots: std::slice::from_ref(&sandbox_policy_cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: true, windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, @@ -295,7 +295,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() { enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: std::slice::from_ref(&path), + workspace_roots: std::slice::from_ref(&sandbox_policy_cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index f6ec38f6c2..34c8958147 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -112,7 +112,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, - workspace_roots: std::slice::from_ref(&native_sandbox_policy_cwd), + workspace_roots: std::slice::from_ref(&sandbox_policy_cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 3ff50f3f51..6562faa66e 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -208,6 +208,10 @@ impl Approvable for ShellRuntime { } impl ToolRuntime for ShellRuntime { + fn turn_environment<'a>(&self, req: &'a ShellRequest) -> Option<&'a TurnEnvironment> { + Some(&req.turn_environment) + } + fn network_approval_spec( &self, req: &ShellRequest, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index a1b9b33c6d..8ebd26aa90 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -255,6 +255,10 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn turn_environment<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b TurnEnvironment> { + Some(&req.turn_environment) + } + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { Some(&req.sandbox_cwd) } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index a23bd5a000..cd3fb16af9 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -8,6 +8,7 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; use crate::state::SessionServices; use crate::tools::hook_names::HookToolName; use crate::tools::network_approval::NetworkApprovalSpec; @@ -27,7 +28,6 @@ use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use futures::Future; use futures::future::BoxFuture; @@ -395,6 +395,10 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { + fn turn_environment<'a>(&self, _req: &'a Req) -> Option<&'a TurnEnvironment> { + None + } + fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option { None } @@ -421,7 +425,7 @@ pub(crate) struct SandboxAttempt<'a> { pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a PathUri, - pub(crate) workspace_roots: &'a [AbsolutePathBuf], + pub(crate) workspace_roots: &'a [PathUri], pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>, pub use_legacy_landlock: bool, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, @@ -464,10 +468,15 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; + let workspace_roots = self + .workspace_roots + .iter() + .map(PathUri::to_abs_path) + .collect::>>()?; Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( request, options, - self.workspace_roots.to_vec(), + workspace_roots, )) } @@ -501,21 +510,14 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; - let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( - request, - options, - self.workspace_roots.to_vec(), - ); + let mut exec_request = + crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options, Vec::new()); exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), - workspace_roots: self - .workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect(), + workspace_roots: self.workspace_roots.to_vec(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index ee2530a386..9e1d00d0e2 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -221,7 +221,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { enforce_managed_network: true, manager: &manager, sandbox_cwd: &cwd_uri, - workspace_roots: std::slice::from_ref(&cwd), + workspace_roots: std::slice::from_ref(&cwd_uri), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b95a003c5b..65eadaca95 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -109,6 +109,7 @@ pub fn local(cwd: AbsolutePathBuf) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: vec![PathUri::from_abs_path(&cwd)], } } @@ -203,6 +204,7 @@ pub async fn test_env() -> Result { let selection = TurnEnvironmentSelection { environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(), cwd: cwd_uri.clone(), + workspace_roots: vec![cwd_uri.clone()], }; let cwd = if remote_env == TestEnvironment::WineExec { // TODO(anp): Convert `Config::cwd` to `LegacyAppPathString` and remove this @@ -686,7 +688,8 @@ impl TestCodexBuilder { .await? } (None, None) => { - let environments = thread_manager.default_environment_selections(&config.cwd); + let environments = thread_manager + .default_environment_selections(&config.cwd, &config.workspace_roots); Box::pin( thread_manager.start_thread_with_options(StartThreadOptions { config: config.clone(), 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 ff80316061..9f5b4d2f35 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 @@ -108,6 +108,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::parse("file:///C:/codex-home")?, + workspace_roots: Vec::new(), }], ); diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 7c73b90603..3d2e88d8e4 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -705,10 +705,12 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), + workspace_roots: Vec::new(), }, TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_host_native_path(local_root.path())?, + workspace_roots: Vec::new(), }, ], thread_extension_init: Default::default(), diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index d8142723b4..aa134fb1ea 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1653,6 +1653,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&shared_cwd), + workspace_roots: Vec::new(), }, ]; test.codex diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 437155f325..d3da765289 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -261,6 +261,7 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]; diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 5f8c503496..e93ba87478 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -302,6 +302,7 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Some(vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), + workspace_roots: Vec::new(), }]), ) .await?; @@ -1134,6 +1135,7 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }; let multi_env_output = exec_command_routing_output( &test, @@ -1290,6 +1292,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ], AskForApproval::OnRequest, @@ -1431,6 +1434,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]), ) @@ -1514,6 +1518,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]; let local_patch = format!( @@ -1715,6 +1720,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&remote_cwd), + workspace_roots: Vec::new(), }, ]), ) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 38c2a7d771..4a7d85231d 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -590,6 +590,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd_uri, + workspace_roots: Vec::new(), }; let relative_call_id = "call-view-image-relative-multi-env"; let absolute_call_id = "call-view-image-absolute-multi-env"; diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 76d0df7954..61fb9f9af7 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -199,6 +199,7 @@ async fn selected_executor_catalog_follows_step_availability_and_reuses_its_cach let turn_environment = TurnEnvironmentSelection { environment_id: "turn-env".to_string(), cwd: PathUri::parse("file:///workspace").expect("cwd URI"), + workspace_roots: Vec::new(), }; let available_sections = registry.context_contributors()[0] .contribute_world_state(WorldStateContributionInput { @@ -593,6 +594,7 @@ async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> Te environments: &[TurnEnvironmentSelection { environment_id: "env-1".to_string(), cwd: PathUri::parse("file:///workspace").expect("cwd URI"), + workspace_roots: Vec::new(), }], ready_selected_capability_roots: &selected_roots, session_store: &session_store, diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 903a1743ff..9219edaeb5 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -324,7 +324,7 @@ impl MemoryStartupContext { ) -> anyhow::Result { let environments = self .thread_manager - .default_environment_selections(&config.cwd); + .default_environment_selections(&config.cwd, &config.workspace_roots); let NewThread { thread_id, thread, .. } = self diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 57a36c3312..5af7fccc10 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -134,6 +134,7 @@ pub fn strip_user_message_prefix(text: &str) -> &str { pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: PathUri, + pub workspace_roots: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -453,10 +454,6 @@ pub struct ThreadSettingsOverrides { /// Updated fallback `cwd` and environments supplied together as a complete pair. pub environments: Option, - /// Updated runtime workspace roots used to materialize symbolic - /// `:workspace_roots` filesystem permissions. - pub workspace_roots: Option>, - /// Updated profile-defined workspace roots for status summaries and /// per-turn config reconstruction. pub profile_workspace_roots: Option>,