From 393f64565ab46f09d99ca4d9bd973537e72a114b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 14 Jul 2026 04:36:19 +0000 Subject: [PATCH] Scope runtime workspace roots to execution environments (#32952) ## Why Multi-environment turns can run against different filesystems, so a single thread-wide workspace-root list cannot accurately describe every selected environment. ## What changed - Add optional `runtimeWorkspaceRoots` to each app-server environment selection. Omitted roots default to that environment's `cwd`, while an empty list selects no roots. - Carry environment-native roots through turn selection, model-visible context, permission materialization, and tool sandboxing. - Keep the top-level `runtimeWorkspaceRoots` field as a compatibility input when constructing default environment selections; explicit selections use their own roots. ## Testing - Cover thread and turn API defaults, overrides, empty roots, and schema round-tripping. - Verify foreign roots remain model-visible and remote execution applies the target environment's roots before sandbox selection. GitOrigin-RevId: c6e7a62883c45c676c153055f46a55ee15a323a4 --- .../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 | 6 +- .../src/protocol/v2/tests.rs | 8 +- .../src/protocol/v2/turn.rs | 3 + codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/request_processors.rs | 19 ++ .../request_processors/thread_lifecycle.rs | 3 +- .../request_processors/thread_processor.rs | 28 +-- .../src/request_processors/thread_summary.rs | 22 +- .../src/request_processors/turn_processor.rs | 106 +++++---- .../tests/common/test_app_server.rs | 1 + .../app-server/tests/suite/v2/mcp_tool.rs | 1 + .../suite/v2/selected_capability_stack.rs | 3 + .../tests/suite/v2/selected_environment.rs | 33 +-- .../tests/suite/v2/thread_settings_update.rs | 22 +- .../app-server/tests/suite/v2/thread_start.rs | 70 +++++- .../app-server/tests/suite/v2/turn_start.rs | 30 ++- codex-rs/core/src/agents_md_tests.rs | 1 + codex-rs/core/src/codex_thread.rs | 3 - .../core/src/context/environment_context.rs | 12 +- .../src/context/world_state/environment.rs | 9 +- .../world_state/environment_render_tests.rs | 12 +- codex-rs/core/src/environment_selection.rs | 40 +++- codex-rs/core/src/mcp_openai_file.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/handlers.rs | 2 - codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/session.rs | 58 +++-- codex-rs/core/src/session/tests.rs | 216 ++---------------- .../core/src/session/tests/guardian_tests.rs | 8 + codex-rs/core/src/session/turn_context.rs | 41 ++-- 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/shell_tests.rs | 1 + .../core/src/tools/handlers/view_image.rs | 12 +- codex-rs/core/src/tools/orchestrator.rs | 26 ++- .../core/src/tools/runtimes/apply_patch.rs | 10 +- .../src/tools/runtimes/apply_patch_tests.rs | 5 +- codex-rs/core/src/tools/runtimes/mod_tests.rs | 2 +- codex-rs/core/src/tools/runtimes/shell.rs | 4 + .../core/src/tools/runtimes/shell_tests.rs | 1 + .../core/src/tools/runtimes/unified_exec.rs | 5 + codex-rs/core/src/tools/sandboxing.rs | 25 +- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/core/src/tools/spec_plan_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 6 +- .../remote_env_windows_test.rs | 10 +- codex-rs/core/tests/suite/agents_md.rs | 2 + codex-rs/core/tests/suite/apply_patch_cli.rs | 1 + .../core/tests/suite/model_visible_layout.rs | 62 +++++ codex-rs/core/tests/suite/network_approval.rs | 1 + codex-rs/core/tests/suite/remote_env.rs | 166 ++++++++++++++ codex-rs/core/tests/suite/view_image.rs | 3 +- codex-rs/ext/skills/tests/skills_extension.rs | 2 + codex-rs/memories/write/src/runtime.rs | 2 +- codex-rs/protocol/src/protocol.rs | 5 +- 62 files changed, 767 insertions(+), 444 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 80b82ef13eab..c1c43bd65639 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4503,6 +4503,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + "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 bfc656106935..3f8bcc9429a2 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 @@ -20339,6 +20339,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + "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 c45d0f40a49d..5a69ec14aa9b 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 @@ -18118,6 +18118,16 @@ }, "environmentId": { "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + "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 3819f1f7611e..8bf5ae8bef72 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 defaults to `cwd`.", + "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 cc4869e1d18c..b38792e448bd 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 defaults to `cwd`.", + "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 cb93ba396411..f51fcf33ee52 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,8 @@ // 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 defaults to `cwd`. + */ +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 c529e2aa77da..5c675097d78c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -4227,13 +4227,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] } ], })) @@ -4244,6 +4246,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!( @@ -4257,7 +4260,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 af99b4b2e725..62a289cd2541 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,9 @@ pub enum TurnStatus { pub struct TurnEnvironmentParams { pub environment_id: String, pub cwd: LegacyAppPathString, + /// Environment-native runtime workspace roots. Omitted defaults to `cwd`. + #[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 e4a772b0689c..b23003260b35 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 runtime workspace roots used when app-server creates default environment selections; 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`. Explicit environments ignore the top-level roots; omitted per-environment roots default to that environment's `cwd`, while an empty list explicitly selects no roots. 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 e09123c811b1..53bcaa2dd00d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -580,9 +580,28 @@ 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(|| vec![cwd.clone()]); selections.push(TurnEnvironmentSelection { environment_id, cwd, + workspace_roots, }); } thread_manager diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 0fc919421082..25346de7ee1b 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -619,6 +619,7 @@ pub(super) async fn handle_pending_thread_resume_request( } let config_snapshot = pending.config_snapshot; + let sandbox = config_snapshot.sandbox_policy().into(); let cwd = config_snapshot.cwd().clone(); let ThreadConfigSnapshot { model, @@ -626,7 +627,6 @@ pub(super) async fn handle_pending_thread_resume_request( service_tier, approval_policy, approvals_reviewer, - permission_profile, active_permission_profile, workspace_roots, reasoning_effort, @@ -634,7 +634,6 @@ pub(super) async fn handle_pending_thread_resume_request( .. } = config_snapshot; let instruction_sources = pending.instruction_sources; - let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let active_permission_profile = thread_response_active_permission_profile(active_permission_profile); let session_id = conversation.session_configured().session_id.to_string(); 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 ac347a8985f5..04597b921b02 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -974,9 +974,9 @@ 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 environments = + resolve_turn_environment_selections(self.thread_manager.as_ref(), environments)?; let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -1023,7 +1023,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, @@ -1100,7 +1100,7 @@ impl ThreadRequestProcessor { history_mode: Option, session_start_source: Option, thread_source: Option, - environments: Option>, + environment_selections: Option>, service_name: Option, allow_provider_model_fallback: bool, experimental_raw_events: bool, @@ -1113,7 +1113,6 @@ impl ThreadRequestProcessor { .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) .await .map_err(|err| config_load_error(&err))?; - // The user may have requested WorkspaceWrite or DangerFullAccess via // the command line, though in the process of deriving the Config, it // could be downgraded to ReadOnly (perhaps there is no sandbox @@ -1194,10 +1193,10 @@ impl ThreadRequestProcessor { } } - let environments = environments.unwrap_or_else(|| { + let environments = environment_selections.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() { @@ -1322,10 +1321,7 @@ impl ThreadRequestProcessor { /*has_in_progress_turn*/ false, ); - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ); + let sandbox = config_snapshot.sandbox_policy().into(); let cwd = config_snapshot.cwd().clone(); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -2908,10 +2904,7 @@ impl ThreadRequestProcessor { /*has_live_in_progress_turn*/ false, ); let config_snapshot = codex_thread.config_snapshot().await; - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ); + let sandbox = config_snapshot.sandbox_policy().into(); let active_permission_profile = thread_response_active_permission_profile( config_snapshot.active_permission_profile, ); @@ -3670,10 +3663,7 @@ impl ThreadRequestProcessor { /*has_in_progress_turn*/ false, ); let config_snapshot = forked_thread.config_snapshot().await; - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ); + let sandbox = config_snapshot.sandbox_policy().into(); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); let thread_originator = config_snapshot.originator.clone(); diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 139da67bcacb..caf662d6426a 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -172,17 +172,6 @@ pub(crate) fn thread_response_active_permission_profile( active_permission_profile.map(Into::into) } -pub(crate) fn thread_response_sandbox_policy( - permission_profile: &codex_protocol::models::PermissionProfile, - cwd: &Path, -) -> codex_app_server_protocol::SandboxPolicy { - let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - permission_profile, - cwd, - ); - sandbox_policy.into() -} - pub(crate) fn thread_settings_from_config_snapshot( config_snapshot: &ThreadConfigSnapshot, ) -> ThreadSettings { @@ -190,10 +179,7 @@ pub(crate) fn thread_settings_from_config_snapshot( cwd: config_snapshot.cwd().clone(), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), - sandbox_policy: thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), - ), + sandbox_policy: config_snapshot.sandbox_policy().into(), active_permission_profile: thread_response_active_permission_profile( config_snapshot.active_permission_profile.clone(), ), @@ -225,7 +211,11 @@ pub(crate) fn thread_settings_from_core_snapshot( personality, collaboration_mode, } = snapshot; - let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); + let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &permission_profile, + cwd.as_path(), + ) + .into(); ThreadSettings { sandbox_policy, cwd, 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 cc872895a41f..93d15a294ef5 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -108,7 +108,6 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, environments: Option, - runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox_policy: Option, @@ -482,6 +481,9 @@ impl TurnRequestProcessor { )) })?; + let runtime_workspace_roots = params + .runtime_workspace_roots + .map(resolve_runtime_workspace_roots); let environment_selections = resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; @@ -496,7 +498,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, + environment_selections, + ) .await; let thread_settings = self .build_thread_settings_overrides( @@ -504,7 +511,6 @@ impl TurnRequestProcessor { ThreadSettingsBuildParams { method: "turn/start", environments, - runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, @@ -583,33 +589,63 @@ 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 { + if cwd.is_none() && workspace_roots.is_none() && environment_selections.is_none() { + return None; + } + + // Explicit environment selections own their roots and pass through unchanged. Top-level + // `runtimeWorkspaceRoots` is only a compatibility input for default environments. + if let Some(environment_selections) = environment_selections { + let legacy_fallback_cwd = match cwd { + Some(cwd) => cwd, + None => match environment_selections + .iter() + .find(|selection| selection.environment_id == LOCAL_ENVIRONMENT_ID) + .and_then(|selection| selection.cwd.to_abs_path().ok()) + { 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()) + None => thread.config_snapshot().await.cwd().clone(), + }, + }; + return Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )); + } + + let snapshot = thread.config_snapshot().await; + let current_cwd = snapshot.cwd().clone(); + let legacy_fallback_cwd = cwd.unwrap_or_else(|| current_cwd.clone()); + let workspace_roots = match workspace_roots { + Some(workspace_roots) => workspace_roots, + None => { + // Match the pre-environment partial-update behavior: a cwd-only update retargets + // the old cwd root while preserving any additional roots. Deduplicate because the + // new cwd may already be present as an additional root. + let mut retargeted_workspace_roots = Vec::new(); + for root in snapshot.workspace_roots { + let root = if root == current_cwd { + legacy_fallback_cwd.clone() + } else { + root + }; + if !retargeted_workspace_roots.contains(&root) { + retargeted_workspace_roots.push(root); } - }; - Some(TurnEnvironmentSelections::new( - legacy_fallback_cwd, - environment_selections, - )) + } + retargeted_workspace_roots } - } + }; + let environment_selections = self + .thread_manager + .default_environment_selections(&legacy_fallback_cwd, &workspace_roots); + Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )) } async fn build_thread_settings_overrides( @@ -620,7 +656,6 @@ impl TurnRequestProcessor { let ThreadSettingsBuildParams { method, environments, - runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -641,7 +676,6 @@ impl TurnRequestProcessor { let collaboration_mode = collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); - let runtime_workspace_roots_request = runtime_workspace_roots; let has_environment_override = environments.is_some(); // `thread/settings/update` only acknowledges that the update was queued. // Clients that send dependent partial updates should wait for @@ -653,7 +687,6 @@ impl TurnRequestProcessor { }; let has_any_overrides = has_environment_override - || runtime_workspace_roots_request.is_some() || approval_policy.is_some() || approvals_reviewer.is_some() || sandbox_policy.is_some() @@ -665,8 +698,6 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = - runtime_workspace_roots_request.map(resolve_runtime_workspace_roots); let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -683,11 +714,6 @@ impl TurnRequestProcessor { cwd: environments .as_ref() .map(|environments| environments.legacy_fallback_cwd.to_path_buf()), - workspace_roots: Some( - runtime_workspace_roots - .clone() - .unwrap_or_else(|| snapshot.workspace_roots.clone()), - ), default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), @@ -726,7 +752,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(), @@ -749,7 +774,6 @@ impl TurnRequestProcessor { Ok(codex_protocol::protocol::ThreadSettingsOverrides { environments, - workspace_roots: runtime_workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -774,7 +798,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( @@ -782,7 +811,6 @@ impl TurnRequestProcessor { ThreadSettingsBuildParams { method: "thread/settings/update", environments, - runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, 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 3852399999b1..656f1dee25cb 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 ff8d0fcacd4b..453b4c0abf48 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 9e8983840b73..20ec96481db1 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/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs index 6dd90eb51a62..6dfeea20f195 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_environment.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -50,6 +50,13 @@ async fn thread_start_reports_selected_environment_metadata() -> Result<()> { .build() .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + let selected_workspace_roots = app_server + .auto_env()? + .selection() + .workspace_roots + .iter() + .filter_map(|root| root.to_abs_path().ok()) + .collect::>(); let request_id = app_server .send_thread_start_request_with_auto_env(ThreadStartParams::default()) @@ -67,17 +74,12 @@ async fn thread_start_reports_selected_environment_metadata() -> Result<()> { } = to_response(response)?; let host_cwd = codex_home.path().to_path_buf().abs().canonicalize()?; let cwd = cwd.canonicalize()?; - let runtime_workspace_roots = runtime_workspace_roots - .into_iter() - .map(|root| root.canonicalize()) - .collect::>>()?; assert_eq!( (cwd, runtime_workspace_roots, active_permission_profile), ( // TODO(anp): Return the selected environment's native cwd from thread/start. - host_cwd.clone(), - // TODO(anp): Derive runtime workspace roots from the selected remote environment. - vec![host_cwd], + host_cwd, + selected_workspace_roots, // TODO(anp): Report the implicit built-in permission profile instead of None. None, ) @@ -207,11 +209,7 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { app_server.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - let ThreadStartResponse { - thread, - runtime_workspace_roots, - .. - } = to_response(response)?; + let ThreadStartResponse { thread, .. } = to_response(response)?; timeout( DEFAULT_READ_TIMEOUT, app_server.start_turn_and_wait_for_completion(text_turn_params( @@ -246,16 +244,5 @@ async fn turn_model_context_uses_selected_environment() -> Result<()> { )), ) ); - let [runtime_workspace_root] = runtime_workspace_roots.as_slice() else { - anyhow::bail!("expected one runtime workspace root"); - }; - let expected_workspace_roots = format!( - "{}", - runtime_workspace_root.as_path().display() - ); - // TODO(anp): Derive model-visible workspace roots from the selected remote environment and - // render them using its native path convention. - assert!(environment_context.contains(&expected_workspace_roots)); - Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index bf3d6261b82a..ed6196d7aa27 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -107,6 +107,7 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() ]); let response_mock = responses::mount_sse_once(&server, body).await; let codex_home = TempDir::new()?; + let initial_workspace = TempDir::new()?; let workspace = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -115,7 +116,19 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() .build() .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let thread = start_thread(&mut mcp).await?.thread; + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(initial_workspace.path().to_string_lossy().into_owned()), + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let thread = to_response::(response)?.thread; send_thread_settings_update( &mut mcp, @@ -149,6 +162,13 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() )), "default environment should use the updated cwd: {environment_context}" ); + assert!( + environment_context.contains(&format!( + "{}", + workspace.path().to_string_lossy() + )), + "default workspace root should use the updated cwd: {environment_context}" + ); Ok(()) } 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 66c45a9fb582..f6dbc85ba7fd 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -16,6 +16,8 @@ use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; +#[cfg(not(windows))] +use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::TextPosition; use codex_app_server_protocol::TextRange; @@ -532,9 +534,10 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { + .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.to_string_lossy().to_string()), runtime_workspace_roots: Some(vec![extra_root.abs()]), + sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() }) .await?; @@ -547,11 +550,60 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let ThreadStartResponse { cwd: response_cwd, runtime_workspace_roots, + sandbox, .. } = to_response::(resp)?; assert_eq!(response_cwd, cwd.abs()); assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); + #[cfg(windows)] + let _ = sandbox; + #[cfg(not(windows))] + { + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { + panic!("expected workspace-write sandbox"); + }; + assert!( + writable_roots.contains(&extra_root.abs().canonicalize()?), + "legacy sandbox projection should include the runtime workspace root" + ); + } + + let environment_root = cwd.join("environment-root"); + std::fs::create_dir_all(&environment_root)?; + let mut environment = mcp.auto_env_params()?; + environment.runtime_workspace_roots = Some(vec![environment_root.abs().into()]); + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + runtime_workspace_roots: Some(vec![extra_root.abs()]), + environments: Some(vec![environment]), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + runtime_workspace_roots, + sandbox, + .. + } = to_response::(resp)?; + assert_eq!(runtime_workspace_roots, vec![environment_root.abs()]); + #[cfg(windows)] + let _ = sandbox; + #[cfg(not(windows))] + { + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox else { + panic!("expected workspace-write sandbox"); + }; + assert!( + writable_roots.contains(&environment_root.abs().canonicalize()?), + "legacy sandbox projection should include the environment workspace root" + ); + } Ok(()) } @@ -576,7 +628,7 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { + .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.path().to_string_lossy().to_string()), ..Default::default() }) @@ -606,6 +658,10 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config_before = std::fs::read_to_string(&config_path)?; + let workspace = TempDir::new()?; + let workspace = workspace.path().to_path_buf().abs(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -615,12 +671,12 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let request_id = mcp .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.to_string_lossy().into_owned()), + sandbox: Some(SandboxMode::WorkspaceWrite), environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from( - codex_home.path().to_path_buf(), - )? - .into(), + cwd: workspace.into(), + runtime_workspace_roots: None, }]), ..Default::default() }) @@ -635,6 +691,7 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result assert_eq!(error.id, RequestId::Integer(request_id)); assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); assert_eq!(error.error.message, "unknown turn environment id `missing`"); + assert_eq!(std::fs::read_to_string(config_path)?, config_before); Ok(()) } @@ -657,6 +714,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 10e1edcf699a..15c557641fe0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -47,6 +47,7 @@ use codex_app_server_protocol::ThreadDeletedNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -61,6 +62,7 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::WarningNotification; use codex_core::test_support::all_model_presets; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_features::FEATURES; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; @@ -1374,6 +1376,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() }) @@ -2433,7 +2436,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { } #[tokio::test] -async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { +async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns() -> Result<()> { // TODO(anp): Materialize cwd and shell-display fixtures in the selected remote environment. skip_if_remote!(Ok(()), "cwd fixtures are only materialized on the host"); skip_if_no_network!(Ok(())); @@ -2537,10 +2540,15 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .await??; mcp.clear_message_buffer(); - // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd + // Select a new local cwd without the top-level compatibility parameter. The inherited + // workspace-write sandbox must follow the local environment cwd. let second_turn = mcp .send_turn_start_request(TurnStartParams { - environments: None, + environments: Some(vec![TurnEnvironmentParams { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: second_cwd.abs().into(), + runtime_workspace_roots: None, + }]), thread_id: thread.id.clone(), client_user_message_id: None, input: vec![V2UserInput::Text { @@ -2549,11 +2557,11 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, additional_context: None, - cwd: Some(second_cwd.clone()), + cwd: None, runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + sandbox_policy: None, permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), @@ -2570,6 +2578,17 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(second_turn)), ) .await??; + let settings_updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + let settings_updated: ThreadSettingsUpdatedNotification = serde_json::from_value( + settings_updated + .params + .context("thread/settings/updated should include params")?, + )?; + assert_eq!(settings_updated.thread_settings.cwd, second_cwd.abs()); let command_exec_item = timeout(DEFAULT_READ_TIMEOUT, async { loop { @@ -2912,6 +2931,7 @@ fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option( .expect("local environment"), ), PathUri::from_abs_path(&cwd), + Vec::new(), /*shell*/ None, ) }) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 257a32733b29..f0c4244586f9 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -143,7 +143,6 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadSettingsOverrides { pub environments: 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/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index cae2d00ea266..da6279bd66ce 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -4,7 +4,7 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashSet; use std::path::PathBuf; @@ -33,14 +33,18 @@ enum ManagedFileSystemContext { impl FileSystemContext { pub(super) fn from_permission_profile( permission_profile: &PermissionProfile, - workspace_roots: &[AbsolutePathBuf], + workspace_roots: &[PathUri], ) -> Self { + let materialized_workspace_roots = workspace_roots + .iter() + .filter_map(|workspace_root| workspace_root.to_abs_path().ok()) + .collect::>(); let permission_profile = permission_profile .clone() - .materialize_project_roots_with_workspace_roots(workspace_roots); + .materialize_project_roots_with_workspace_roots(&materialized_workspace_roots); let workspace_roots = workspace_roots .iter() - .map(|root| root.to_string_lossy().into_owned()) + .map(PathUri::inferred_native_path_string) .collect(); let permission_profile = match permission_profile { PermissionProfile::Managed { file_system, .. } => { diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs index c2fd170e300b..0ce57840bd9c 100644 --- a/codex-rs/core/src/context/world_state/environment.rs +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -6,6 +6,7 @@ use crate::context::environment_context::NetworkContext; use crate::context::environment_context::push_xml_escaped_text; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -27,14 +28,18 @@ impl EnvironmentsState { turn_context: &TurnContext, environments: &TurnEnvironmentSnapshot, ) -> Self { + let workspace_roots = environments + .primary() + .map(TurnEnvironment::workspace_roots) + .unwrap_or_default(); Self { environments: environment_states(environments), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), network: network_from_turn_context(turn_context), filesystem: Some(FileSystemContext::from_permission_profile( - &turn_context.permission_profile, - &turn_context.config.effective_workspace_roots(), + turn_context.config.permissions.permission_profile(), + workspace_roots, )), subagents: None, } diff --git a/codex-rs/core/src/context/world_state/environment_render_tests.rs b/codex-rs/core/src/context/world_state/environment_render_tests.rs index 1567080e2efb..bb056e20564e 100644 --- a/codex-rs/core/src/context/world_state/environment_render_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_render_tests.rs @@ -86,7 +86,7 @@ fn serialize_workspace_write_environment_context() { #[test] fn serialize_environment_context_with_foreign_windows_cwd() { - let context = environment_state( + let mut context = environment_state( [environment( "remote", PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), @@ -97,12 +97,17 @@ fn serialize_environment_context_with_foreign_windows_cwd() { /*network*/ None, /*subagents*/ None, ); + context.filesystem = Some(FileSystemContext::from_permission_profile( + &PermissionProfile::Disabled, + &[PathUri::parse("file:///D:/workspace").expect("Windows workspace root URI")], + )); assert_eq!( context.render(), r#" C:\windows powershell + D:\workspace "# ); } @@ -189,7 +194,10 @@ fn serialize_environment_context_with_full_filesystem_profile() { ); context.filesystem = Some(FileSystemContext::from_permission_profile( &workspace_write_permission_profile_with_private_denials(), - &[repo.clone(), other_repo.clone()], + &[ + PathUri::from_abs_path(&repo), + PathUri::from_abs_path(&other_repo), + ], )); let expected = format!( diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index ef6a3ec5ea93..53857e596e77 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() } @@ -175,8 +177,13 @@ impl ThreadEnvironments { } else { Some(local_shell) }; - let mut turn_environment = - TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell); + let mut turn_environment = TurnEnvironment::new( + selection.environment_id, + environment, + selection.cwd, + selection.workspace_roots, + shell, + ); let task = shell_snapshot .build(turn_environment.clone()) .boxed() @@ -406,10 +413,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 +442,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 +464,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 +486,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 +507,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 +517,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 +538,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 +576,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 +585,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 +613,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 +661,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 +725,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 +777,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 +834,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())) @@ -822,6 +844,7 @@ url = "ws://127.0.0.1:8765" selection.environment_id.clone(), Arc::clone(&inherited_environment), selection.cwd.clone(), + Vec::new(), /*shell*/ None, ); let manager = Arc::new(EnvironmentManager::without_environments()); @@ -865,6 +888,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; @@ -878,6 +902,7 @@ url = "ws://127.0.0.1:8765" REMOTE_ENVIRONMENT_ID.to_string(), remote_environment.clone(), cwd_uri.clone(), + Vec::new(), /*shell*/ None, )], starting: Vec::new(), @@ -889,6 +914,7 @@ url = "ws://127.0.0.1:8765" REMOTE_ENVIRONMENT_ID.to_string(), remote_environment, cwd_uri, + Vec::new(), /*shell*/ None, ), ], diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 0df11b2f1231..02c1587e04ea 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -229,6 +229,7 @@ mod tests { primary.environment_id.clone(), Arc::clone(&primary.environment), PathUri::from_abs_path(&cwd), + Vec::new(), primary.shell.clone(), ); } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index a1d06d1546a2..6bd857b96fb6 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1169,6 +1169,7 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul "remote".to_string(), environment, secondary_cwd.clone(), + Vec::new(), /*shell*/ None, )); diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index cc21755524e3..552c03803df9 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 06d90ada0c4e..99bb18ece200 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 fc28d575f803..ecabea17566d 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,20 @@ impl SessionConfiguration { &self.environments.environments } + pub(super) fn primary_workspace_roots(&self) -> Vec { + self.environments + .environments + .first() + .map(|environment| { + environment + .workspace_roots + .iter() + .filter_map(|workspace_root| workspace_root.to_abs_path().ok()) + .collect() + }) + .unwrap_or_default() + } + pub(crate) fn codex_home(&self) -> &AbsolutePathBuf { &self.codex_home } @@ -128,10 +139,12 @@ impl SessionConfiguration { } pub(super) fn permission_profile(&self) -> PermissionProfile { - self.permission_profile_state - .permission_profile() - .clone() - .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + self.permission_profile_state.permission_profile().clone() + } + + fn materialized_permission_profile(&self) -> PermissionProfile { + self.permission_profile() + .materialize_project_roots_with_workspace_roots(&self.primary_workspace_roots()) } pub(super) fn active_permission_profile(&self) -> Option { @@ -159,7 +172,7 @@ impl SessionConfiguration { } pub(super) fn sandbox_policy(&self) -> SandboxPolicy { - let permission_profile = self.permission_profile(); + let permission_profile = self.materialized_permission_profile(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &permission_profile, self.cwd(), @@ -167,7 +180,8 @@ impl SessionConfiguration { } pub(super) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile().file_system_sandbox_policy() + self.materialized_permission_profile() + .file_system_sandbox_policy() } pub(super) fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { @@ -183,10 +197,10 @@ impl SessionConfiguration { service_tier: self.service_tier.clone(), approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, - permission_profile: self.permission_profile(), + permission_profile: self.materialized_permission_profile(), active_permission_profile: self.active_permission_profile(), environments: self.environments.clone(), - workspace_roots: self.workspace_roots.clone(), + workspace_roots: self.primary_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 +279,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 +413,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, @@ -1168,7 +1164,7 @@ impl Session { service_tier: session_configuration.service_tier.clone(), approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, - permission_profile: session_configuration.permission_profile(), + permission_profile: session_configuration.materialized_permission_profile(), active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd().clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f748566ef30a..c82a2f98d550 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -50,9 +50,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; @@ -182,6 +180,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; @@ -1084,6 +1083,10 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an } impl crate::tools::sandboxing::ToolRuntime<(), ()> for ProbeToolRuntime { + fn workspace_roots<'a>(&self, _req: &'a ()) -> &'a [PathUri] { + &[] + } + async fn run( &mut self, _req: &(), @@ -2962,52 +2965,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; @@ -3807,7 +3764,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), @@ -3914,7 +3870,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), @@ -4451,7 +4406,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), @@ -4609,7 +4563,9 @@ 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.primary_workspace_roots(), + ); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -4671,7 +4627,9 @@ 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.primary_workspace_roots(), + ); expected_file_system_policy.glob_scan_max_depth = Some(2); expected_file_system_policy.entries.push(deny_entry); assert_eq!( @@ -4727,94 +4685,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"); @@ -5025,60 +4895,6 @@ enabled = false ); } -#[tokio::test] -async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_update() { - let mut session_configuration = make_session_configuration_for_tests().await; - let workspace = tempfile::tempdir().expect("create temp dir"); - let original_cwd = workspace.path().join("repo-a").abs(); - 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, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &sandbox_policy, - session_configuration.cwd(), - ); - session_configuration - .set_permission_profile_for_tests( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - NetworkSandboxPolicy::from(&sandbox_policy), - ), - ) - .expect("set permission profile"); - - let updated = session_configuration - .apply(&SessionSettingsUpdate { - environments: Some(TurnEnvironmentSelections::new( - project_root.clone(), - Vec::new(), - )), - ..Default::default() - }) - .expect("cwd-only update should succeed"); - - assert_eq!(updated.workspace_roots, vec![project_root.clone()]); - assert!( - updated - .file_system_sandbox_policy() - .can_write_path_with_cwd(project_root.as_path(), updated.cwd().as_path()), - "cwd-only update should keep the new cwd writable" - ); - assert!( - !updated - .file_system_sandbox_policy() - .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()), - "cwd-only update should not keep the old implicit cwd writable" - ); -} - #[tokio::test] async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_update() { let mut session_configuration = make_session_configuration_for_tests().await; @@ -5331,7 +5147,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), @@ -5463,7 +5278,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), @@ -5717,7 +5531,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), @@ -5825,7 +5638,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), @@ -6275,6 +6087,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir "remote".to_string(), current_environment.environment, PathUri::from_abs_path(&environment_cwd), + Vec::new(), current_environment.shell, ); @@ -6907,6 +6720,7 @@ async fn primary_environment_uses_first_turn_environment() { "second".to_string(), Arc::clone(&first_environment.environment), second_cwd_uri.clone(), + Vec::new(), /*shell*/ None, )); @@ -7595,7 +7409,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), @@ -8148,6 +7961,7 @@ async fn record_context_updates_emits_environment_item_for_cwd_changes() { environment.environment_id, environment.environment, PathUri::from_abs_path(&cwd), + Vec::new(), environment.shell, ); @@ -8207,6 +8021,7 @@ async fn record_context_updates_omits_environment_item_when_disabled() { environment.environment_id, environment.environment, PathUri::from_abs_path(&test_path_buf("/new-repo").abs()), + Vec::new(), environment.shell, ); @@ -8935,6 +8750,7 @@ async fn turn_context_item_stores_local_cwd() { "remote".to_string(), environment.environment, cwd, + Vec::new(), environment.shell, ); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index d13239d810e3..2532efc0d3df 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -303,6 +303,10 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli .expect("test setup should allow enabling request permissions"); turn_context_raw.permission_profile = codex_protocol::models::PermissionProfile::Disabled; let mut config = (*turn_context_raw.config).clone(); + config + .permissions + .set_permission_profile(codex_protocol::models::PermissionProfile::Disabled) + .expect("test setup should allow disabling the permission profile"); config.codex_linux_sandbox_exe = codex_linux_sandbox_exe_or_skip!(); config .features @@ -414,6 +418,10 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ .expect("test setup should allow updating approval policy"); turn_context_raw.permission_profile = codex_protocol::models::PermissionProfile::Disabled; let mut config = (*turn_context_raw.config).clone(); + config + .permissions + .set_permission_profile(codex_protocol::models::PermissionProfile::Disabled) + .expect("test setup should allow disabling the permission profile"); config.approvals_reviewer = ApprovalsReviewer::User; config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 52a809e24084..b0e6bafa641a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -46,6 +46,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, } @@ -55,12 +56,14 @@ impl TurnEnvironment { environment_id: String, environment: Arc, cwd: PathUri, + workspace_roots: Vec, shell: Option, ) -> Self { Self { environment_id, environment, cwd, + workspace_roots, shell, shell_snapshot: futures::future::ready(None).boxed().shared(), } @@ -80,10 +83,15 @@ impl TurnEnvironment { &self.cwd } + pub(crate) fn workspace_roots(&self) -> &[PathUri] { + &self.workspace_roots + } + pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), } } } @@ -94,6 +102,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() } @@ -317,10 +326,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(), @@ -330,19 +340,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 @@ -438,10 +443,11 @@ impl Session { 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(); + let workspace_roots = session_configuration.primary_workspace_roots(); + 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; @@ -476,10 +482,6 @@ impl Session { Self::build_per_turn_config(session_configuration, session_configuration.cwd().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 } @@ -531,6 +533,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(), @@ -541,7 +544,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(), )); @@ -578,7 +581,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, @@ -702,7 +705,7 @@ impl Session { multi_agent_runtime: TurnMultiAgentRuntime, ) -> Arc { let turn_environments = self.services.turn_environments.snapshot().await; - let primary_turn_environment = turn_environments.primary().cloned(); + let primary_turn_environment = turn_environments.primary(); // TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so // a foreign primary environment does not fall back to the session's host cwd. let cwd = primary_turn_environment diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index c51c90d2ab50..f5f133e86970 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -510,8 +510,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( @@ -659,6 +664,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, @@ -790,6 +796,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() @@ -836,6 +843,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, @@ -869,6 +877,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() @@ -1057,6 +1066,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( @@ -1373,7 +1383,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, @@ -1412,8 +1426,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, @@ -1455,7 +1472,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 3a141c4021cf..e40425ede575 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -839,6 +839,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 6326dbc1adf2..0950174ba444 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 0d2e889db9d9..13a75bf4c2e6 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -143,7 +143,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/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index ebfbf9127c36..90fd27d87815 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -109,6 +109,7 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() { .environment, ), PathUri::from_abs_path(&selected_cwd), + Vec::new(), Some(selected_shell), ); let mut expected_env = create_env( diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 625b5b871308..7ee6f53ec5ca 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 @@ -272,6 +270,7 @@ mod tests { current.environment_id, current.environment, PathUri::from_abs_path(&cwd), + Vec::new(), current.shell, ); } @@ -382,7 +381,10 @@ mod tests { replace_primary_environment_cwd(&mut turn, image_cwd.clone()); let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); - turn.permission_profile = PermissionProfile::Disabled; + Arc::make_mut(&mut turn.config) + .permissions + .set_permission_profile(PermissionProfile::Disabled) + .expect("set permission profile"); let turn = Arc::new(turn); let result = ViewImageHandler::default() diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 7186ea33fc08..bc243b9073e4 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -152,8 +152,17 @@ impl ToolOrchestrator { // 1) Approval let mut already_approved = false; - let file_system_sandbox_policy = turn_ctx.file_system_sandbox_policy(); - let network_sandbox_policy = turn_ctx.network_sandbox_policy(); + let workspace_roots = tool.workspace_roots(req); + let permission_profile = turn_ctx.config.permissions.permission_profile(); + let materialized_workspace_roots = workspace_roots + .iter() + .filter_map(|workspace_root| workspace_root.to_abs_path().ok()) + .collect::>(); + let permissions = permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&materialized_workspace_roots); + let (file_system_sandbox_policy, network_sandbox_policy) = + permissions.to_runtime_permissions(); let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| { default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy) }); @@ -252,16 +261,15 @@ 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 initial_attempt = SandboxAttempt { sandbox: initial_sandbox, sandbox_requested, - permissions: &turn_ctx.permission_profile, - exec_server_permissions: turn_ctx.config.permissions.permission_profile(), + permissions: &permissions, + exec_server_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, @@ -438,12 +446,12 @@ impl ToolOrchestrator { let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, sandbox_requested: retry_sandbox_requested, - permissions: &turn_ctx.permission_profile, - exec_server_permissions: turn_ctx.config.permissions.permission_profile(), + permissions: &permissions, + exec_server_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 97af94f893aa..2573f4f821fd 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -96,11 +96,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, @@ -214,6 +210,10 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { + fn workspace_roots<'a>(&self, req: &'a ApplyPatchRequest) -> &'a [PathUri] { + req.turn_environment.workspace_roots() + } + 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 767a57b73704..844612c478b2 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -20,6 +20,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: environment_id.to_string(), std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()), PathUri::from_abs_path(&std::env::temp_dir().abs()), + Vec::new(), /*shell*/ None, ) } @@ -219,7 +220,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, @@ -288,7 +289,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 f6ec38f6c26d..34c89581476c 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 145820c76802..1d25672e9228 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -210,6 +210,10 @@ impl Approvable for ShellRuntime { } impl ToolRuntime for ShellRuntime { + fn workspace_roots<'a>(&self, req: &'a ShellRequest) -> &'a [PathUri] { + req.turn_environment.workspace_roots() + } + fn network_approval_spec( &self, req: &ShellRequest, diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs index eaa7adf48cba..9f070490adfc 100644 --- a/codex-rs/core/src/tools/runtimes/shell_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -13,6 +13,7 @@ async fn approval_key_includes_environment_id() { "remote".to_string(), Arc::new(Environment::default_for_tests()), PathUri::from_abs_path(&cwd), + Vec::new(), /*shell*/ None, ), shell_type: None, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 6a67987c2579..5922abcd1780 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -256,6 +256,10 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn workspace_roots<'b>(&self, req: &'b UnifiedExecRequest) -> &'b [PathUri] { + req.turn_environment.workspace_roots() + } + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { Some(&req.sandbox_cwd) } @@ -505,6 +509,7 @@ mod tests { LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), cwd, + Vec::new(), /*shell*/ None, ) } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 25078a9524bb..65acbe5a1207 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -27,7 +27,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; @@ -388,6 +387,8 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { + fn workspace_roots<'a>(&self, req: &'a Req) -> &'a [PathUri]; + fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option { None } @@ -414,7 +415,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, @@ -457,10 +458,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, )) } @@ -494,21 +500,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 ee2530a3863d..9e1d00d0e201 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/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 94ab06253ab7..b7fb9070379a 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -592,6 +592,7 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava .expect("remote test environment"), ), remote_cwd, + Vec::new(), /*shell*/ None, ), ); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a334abffdf06..6c543d075dd3 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)], } } @@ -140,6 +141,7 @@ impl TestEnv { Some(_) => TurnEnvironmentSelection { environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), + workspace_roots: vec![PathUri::from_abs_path(&cwd)], }, None => local(cwd.clone()), }; @@ -207,6 +209,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 @@ -692,7 +695,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 ff8031606109..995d1513efdb 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 @@ -105,9 +105,13 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); let environments = TurnEnvironmentSelections::new( test.config.cwd.clone(), - vec![TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::parse("file:///C:/codex-home")?, + vec![{ + let cwd = PathUri::parse("file:///C:/codex-home")?; + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + workspace_roots: vec![cwd], + } }], ); diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 7c73b9060392..65c377e32134 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![PathUri::from_abs_path(&test.config.cwd)], }, TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_host_native_path(local_root.path())?, + workspace_roots: vec![PathUri::from_host_native_path(local_root.path())?], }, ], 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 d8142723b495..51ea0421ea87 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![PathUri::from_abs_path(&shared_cwd)], }, ]; test.codex diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index d7a396bd6f64..4e68303f2cee 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -4,12 +4,16 @@ use std::sync::Arc; use anyhow::Result; use codex_config::types::Personality; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; +use codex_utils_path_uri::PathUri; use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -78,6 +82,64 @@ fn format_environment_context_subagents_snapshot(subagents: &[&str]) -> String { context_snapshot::format_response_items_snapshot(items.as_slice(), &context_snapshot_options()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_visible_environment_context_preserves_foreign_workspace_roots() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + let test = test_codex().build(&server).await?; + let foreign_root = PathUri::parse("file:///C:/workspace")?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "inspect the workspace".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + environments: Some(TurnEnvironmentSelections::new( + test.config.cwd.clone(), + vec![TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&test.config.cwd), + workspace_roots: vec![foreign_root], + }], + )), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let environment_context = response_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.contains("")) + .expect("model-visible environment context"); + assert!( + environment_context.contains("C:\\workspace"), + "foreign workspace root should remain visible to the model: {environment_context}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 49aa68421524..56656d7199f6 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -1067,6 +1067,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![PathUri::from_abs_path(&remote_cwd)], }, ]; diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 31c8853a3573..89901cc1f9ef 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -14,10 +14,12 @@ use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; +use codex_protocol::models::SandboxPermissions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::AskForApproval; @@ -60,6 +62,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_env; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::test_docker_container_name; use core_test_support::test_target_os; use core_test_support::wait_for_event; @@ -304,6 +307,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![PathUri::from_abs_path(&test.config.cwd)], }]), ) .await?; @@ -568,6 +572,7 @@ async fn deferred_executor_updates_context_and_tools_after_startup() -> Result<( vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), + workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)], }], )), ..Default::default() @@ -805,6 +810,7 @@ async fn deferred_executor_wait_reports_startup_failure() -> Result<()> { vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), + workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)], }], )), ..Default::default() @@ -1153,6 +1159,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![PathUri::from_abs_path(&remote_cwd)], }; let multi_env_output = exec_command_routing_output( &test, @@ -1191,6 +1198,161 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_exec_materializes_target_roots_before_sandbox_selection() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_target_windows!( + Ok(()), + "sandboxed process launch is not supported by the exec-server Windows backend" + ); + skip_if_no_remote_env!(Ok(())); + + const SECRET: &str = "target-root-secret"; + const SECRET_FILE: &str = "secret.txt"; + + let server = start_mock_server().await; + let test = unified_exec_test(&server).await?; + let local_cwd = TempDir::new()?; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-remote-target-roots-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let remote_cwd_uri = PathUri::from_abs_path(&remote_cwd); + test.fs() + .create_directory( + &remote_cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .write_file( + &remote_cwd_uri.join(SECRET_FILE)?, + SECRET.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + + let call_id = "remote-target-root-sandbox"; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + "exec_command", + &json!({ + "shell": "bash", + "cmd": format!("cat {SECRET_FILE}"), + "login": false, + "yield_time_ms": 1_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + "sandbox_permissions": SandboxPermissions::RequireEscalated, + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "try to read the denied remote workspace root".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + environments: Some(TurnEnvironmentSelections::new( + test.config.cwd.clone(), + vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&local_cwd.path().abs()), + workspace_roots: Vec::new(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: remote_cwd_uri.clone(), + workspace_roots: vec![remote_cwd_uri.clone()], + }, + ], + )), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let request = response_mock + .last_request() + .context("model should receive the denied remote command output")?; + let (output, success) = request + .function_call_output_content_and_success(call_id) + .context("remote command output should be model visible")?; + assert_ne!(success, Some(true)); + assert!( + output.is_none_or(|output| !output.contains(SECRET)), + "denied remote workspace contents should not be readable" + ); + + test.fs() + .remove( + &remote_cwd_uri, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1309,6 +1471,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![PathUri::from_abs_path(&remote_cwd)], }, ], AskForApproval::OnRequest, @@ -1450,6 +1613,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![PathUri::from_abs_path(&remote_cwd)], }, ]), ) @@ -1533,6 +1697,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![PathUri::from_abs_path(&remote_cwd)], }, ]; let local_patch = format!( @@ -1734,6 +1899,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![PathUri::from_abs_path(&remote_cwd)], }, ]), ) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f7fd1b22bb81..097237ec4475 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -589,7 +589,8 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let absolute_image_path = image_path_uri.inferred_native_path_string(); let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: remote_cwd_uri, + cwd: remote_cwd_uri.clone(), + workspace_roots: vec![remote_cwd_uri], }; 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 6a9a4294e69a..d9c3d4d746c9 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -200,6 +200,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 { @@ -595,6 +596,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 903a1743ff30..9219edaeb50b 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 fe66cfe67208..4f19670ad040 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -135,6 +135,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)] @@ -454,10 +455,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>,