From 64435bee2224c7f5eb0aae2111e8f9c1c57e5f06 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 11 May 2026 11:23:30 -0700 Subject: [PATCH] Move workspace roots onto thread/session state and stop using active permission profile modifications as an overlay for writable roots. Existing app-server threads now preserve their persisted PermissionProfile value across resume, fork, and turn updates; permissions requests on existing threads only update the active named profile after validating it exists. Workspace roots can be updated independently, and SandboxPolicy::WorkspaceWrite no longer stores its own writable_roots. --- codex-rs/Cargo.lock | 4 +- .../analytics/src/analytics_client_tests.rs | 2 + codex-rs/analytics/src/client_tests.rs | 3 + .../schema/json/ClientRequest.json | 51 +-- .../codex_app_server_protocol.schemas.json | 84 +---- .../codex_app_server_protocol.v2.schemas.json | 84 +---- .../schema/json/v2/CommandExecParams.json | 7 - .../schema/json/v2/ThreadForkParams.json | 39 +- .../schema/json/v2/ThreadForkResponse.json | 40 -- .../schema/json/v2/ThreadResumeParams.json | 39 +- .../schema/json/v2/ThreadResumeResponse.json | 40 -- .../schema/json/v2/ThreadStartParams.json | 36 +- .../schema/json/v2/ThreadStartResponse.json | 40 -- .../schema/json/v2/TurnStartParams.json | 45 +-- .../typescript/v2/ActivePermissionProfile.ts | 8 +- .../v2/ActivePermissionProfileModification.ts | 6 - .../v2/PermissionProfileModificationParams.ts | 6 - .../v2/PermissionProfileSelectionParams.ts | 3 +- .../schema/typescript/v2/SandboxPolicy.ts | 3 +- .../schema/typescript/v2/ThreadForkParams.ts | 6 +- .../typescript/v2/ThreadResumeParams.ts | 6 +- .../schema/typescript/v2/TurnStartParams.ts | 3 +- .../schema/typescript/v2/index.ts | 2 - .../src/protocol/common.rs | 2 + .../src/protocol/v2/permissions.rs | 81 +--- .../src/protocol/v2/tests.rs | 4 +- .../src/protocol/v2/thread.rs | 47 ++- .../src/protocol/v2/turn.rs | 13 +- codex-rs/app-server/README.md | 21 +- .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 3 +- .../command_exec_processor.rs | 9 +- .../request_processors/thread_lifecycle.rs | 5 +- .../request_processors/thread_processor.rs | 306 ++++++++++++++- .../thread_processor_tests.rs | 54 +++ .../src/request_processors/thread_summary.rs | 20 +- .../src/request_processors/turn_processor.rs | 84 +++-- codex-rs/app-server/tests/common/rollout.rs | 2 + .../tests/suite/conversation_summary.rs | 1 + .../app-server/tests/suite/v2/skills_list.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 120 ++++++ .../tests/suite/v2/thread_unarchive.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 64 ++-- .../tests/suite/v2/turn_start_zsh_fork.rs | 11 +- codex-rs/cli/src/debug_sandbox.rs | 16 +- codex-rs/config/src/config_requirements.rs | 4 - codex-rs/config/src/config_toml.rs | 4 +- codex-rs/core/src/agent/control.rs | 71 ++-- codex-rs/core/src/codex_thread.rs | 36 +- .../core/src/config/config_loader_tests.rs | 6 +- codex-rs/core/src/config/config_tests.rs | 103 +++--- codex-rs/core/src/config/mod.rs | 122 +++--- codex-rs/core/src/config/permissions.rs | 4 +- .../context/permissions_instructions_tests.rs | 1 - .../core/src/context_manager/history_tests.rs | 2 + codex-rs/core/src/context_manager/updates.rs | 11 +- codex-rs/core/src/exec.rs | 4 + codex-rs/core/src/exec_tests.rs | 10 +- .../core/src/personality_migration_tests.rs | 1 + codex-rs/core/src/rollout.rs | 4 + codex-rs/core/src/safety_tests.rs | 21 +- codex-rs/core/src/session/handlers.rs | 4 + codex-rs/core/src/session/mod.rs | 7 +- codex-rs/core/src/session/review.rs | 1 + .../session/rollout_reconstruction_tests.rs | 16 + codex-rs/core/src/session/session.rs | 44 ++- codex-rs/core/src/session/tests.rs | 54 ++- codex-rs/core/src/session/turn_context.rs | 14 +- .../src/tools/handlers/apply_patch_tests.rs | 2 - .../handlers/multi_agents/close_agent.rs | 157 ++++---- .../handlers/multi_agents/resume_agent.rs | 201 +++++----- .../src/tools/handlers/multi_agents/spawn.rs | 303 +++++++-------- .../src/tools/handlers/multi_agents_common.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 3 +- .../handlers/multi_agents_v2/close_agent.rs | 179 ++++----- .../tools/handlers/multi_agents_v2/spawn.rs | 348 +++++++++--------- codex-rs/core/src/tools/orchestrator.rs | 3 + .../core/src/tools/runtimes/apply_patch.rs | 1 + .../src/tools/runtimes/apply_patch_tests.rs | 2 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 6 +- codex-rs/core/src/tools/sandboxing.rs | 2 + codex-rs/core/tests/common/test_codex.rs | 2 + codex-rs/core/tests/suite/approvals.rs | 4 - codex-rs/core/tests/suite/exec.rs | 1 + .../core/tests/suite/permissions_messages.rs | 10 +- .../core/tests/suite/personality_migration.rs | 2 + codex-rs/core/tests/suite/prompt_caching.rs | 9 +- .../core/tests/suite/request_permissions.rs | 1 - codex-rs/core/tests/suite/resume_warning.rs | 2 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec/src/lib.rs | 37 +- codex-rs/exec/src/lib_tests.rs | 29 +- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/exec/tests/suite/sandbox.rs | 20 +- codex-rs/file-system/src/lib.rs | 22 +- .../linux-sandbox/tests/suite/landlock.rs | 2 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/memories/write/src/phase2.rs | 33 +- codex-rs/protocol/src/models.rs | 38 +- codex-rs/protocol/src/permissions.rs | 140 +++---- codex-rs/protocol/src/protocol.rs | 41 ++- codex-rs/rollout/Cargo.toml | 1 + codex-rs/rollout/src/config.rs | 17 + codex-rs/rollout/src/metadata_tests.rs | 3 + codex-rs/rollout/src/recorder.rs | 1 + codex-rs/rollout/src/recorder_tests.rs | 4 + codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 1 + codex-rs/sandboxing/src/manager.rs | 13 +- codex-rs/sandboxing/src/manager_tests.rs | 4 + codex-rs/sandboxing/src/seatbelt_tests.rs | 118 +++--- codex-rs/state/src/extract.rs | 8 + codex-rs/state/src/runtime/threads.rs | 2 + codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/thread-store/Cargo.toml | 1 + .../thread-store/src/local/create_thread.rs | 1 + .../thread-store/src/local/list_threads.rs | 1 + .../thread-store/src/local/live_writer.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 2 + .../src/local/update_thread_metadata.rs | 1 + codex-rs/thread-store/src/types.rs | 4 + codex-rs/tui/src/app/thread_routing.rs | 1 - codex-rs/tui/src/app_server_session.rs | 148 +++----- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/permission_compat.rs | 95 ----- codex-rs/tui/src/status/card.rs | 39 +- codex-rs/tui/src/status/tests.rs | 25 +- codex-rs/utils/sandbox-summary/Cargo.toml | 1 - .../sandbox-summary/src/sandbox_summary.rs | 15 +- codex-rs/windows-sandbox-rs/Cargo.toml | 1 - codex-rs/windows-sandbox-rs/src/allow.rs | 78 ++-- codex-rs/windows-sandbox-rs/src/audit.rs | 9 +- .../windows-sandbox-rs/src/elevated_impl.rs | 1 - codex-rs/windows-sandbox-rs/src/lib.rs | 1 - codex-rs/windows-sandbox-rs/src/setup.rs | 53 +-- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 1 - 140 files changed, 2187 insertions(+), 2044 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts delete mode 100644 codex-rs/tui/src/permission_compat.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d98806df6..09c6b6aee9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3478,6 +3478,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-state", + "codex-utils-absolute-path", "codex-utils-path", "codex-utils-string", "pretty_assertions", @@ -3669,6 +3670,7 @@ dependencies = [ "codex-protocol", "codex-rollout", "codex-state", + "codex-utils-absolute-path", "pretty_assertions", "serde", "serde_json", @@ -3996,7 +3998,6 @@ dependencies = [ "codex-core", "codex-model-provider-info", "codex-protocol", - "codex-utils-absolute-path", "pretty_assertions", ] @@ -4051,7 +4052,6 @@ dependencies = [ "chrono", "codex-otel", "codex-protocol", - "codex-utils-absolute-path", "codex-utils-pty", "codex-utils-string", "dirs-next", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 6ca931cba9..14544b67f5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -179,6 +179,7 @@ fn sample_thread_start_response( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -235,6 +236,7 @@ fn sample_thread_resume_response_with_source( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 2ddc21e477..9affa9ed8e 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -157,6 +157,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -174,6 +175,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -191,6 +193,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6351993046..6290b70c2b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1850,31 +1850,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -1889,20 +1864,11 @@ "PermissionProfileSelectionParams": { "oneOf": [ { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "description": "Select a named built-in or user-defined profile. This updates profile identity metadata only; it does not replace the thread's effective permissions profile.", "properties": { "id": { "type": "string" }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, "type": { "enum": [ "profile" @@ -3133,13 +3099,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -3406,7 +3365,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "type": [ @@ -3817,7 +3777,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "type": [ @@ -4197,7 +4158,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. The server rejects this field because the thread permission profile value is not mutable through `turn/start`." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", 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 215842d929..073737ef53 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 @@ -5583,14 +5583,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/v2/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -5598,31 +5590,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -11704,31 +11671,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -11743,20 +11685,11 @@ "PermissionProfileSelectionParams": { "oneOf": [ { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "description": "Select a named built-in or user-defined profile. This updates profile identity metadata only; it does not replace the thread's effective permissions profile.", "properties": { "id": { "type": "string" }, - "modifications": { - "items": { - "$ref": "#/definitions/v2/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, "type": { "enum": [ "profile" @@ -14390,13 +14323,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -15384,7 +15310,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "type": [ @@ -16887,7 +16814,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "type": [ @@ -17929,7 +17857,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. The server rejects this field because the thread permission profile value is not mutable through `turn/start`." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", 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 085744e819..39acdb9988 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 @@ -143,14 +143,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -158,31 +150,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -8253,31 +8220,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -8292,20 +8234,11 @@ "PermissionProfileSelectionParams": { "oneOf": [ { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "description": "Select a named built-in or user-defined profile. This updates profile identity metadata only; it does not replace the thread's effective permissions profile.", "properties": { "id": { "type": "string" }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, "type": { "enum": [ "profile" @@ -10939,13 +10872,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -13208,7 +13134,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "type": [ @@ -14711,7 +14638,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "type": [ @@ -15753,7 +15681,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. The server rejects this field because the thread permission profile value is not mutable through `turn/start`." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index f29483862c..6a1098fda4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -441,13 +441,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 29d67403cd..033b101e7b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -64,48 +64,14 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileSelectionParams": { "oneOf": [ { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "description": "Select a named built-in or user-defined profile. This updates profile identity metadata only; it does not replace the thread's effective permissions profile.", "properties": { "id": { "type": "string" }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, "type": { "enum": [ "profile" @@ -212,7 +178,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 6e74ab4ac8..2415a52bb2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -1160,13 +1127,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 5f07fe0149..8a1ea00d89 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -298,48 +298,14 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileSelectionParams": { "oneOf": [ { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "description": "Select a named built-in or user-defined profile. This updates profile identity metadata only; it does not replace the thread's effective permissions profile.", "properties": { "id": { "type": "string" }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, "type": { "enum": [ "profile" @@ -1091,7 +1057,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 727b7a3fb2..f0c6fa8ba1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -1160,13 +1127,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "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 9a60049a61..984d0cb153 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,48 +90,14 @@ ], "type": "object" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileSelectionParams": { "oneOf": [ { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "description": "Select a named built-in or user-defined profile. This updates profile identity metadata only; it does not replace the thread's effective permissions profile.", "properties": { "id": { "type": "string" }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, "type": { "enum": [ "profile" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index bf03f0fb55..bd669a98be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -1160,13 +1127,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "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 1ef33d4301..7d9eb91d06 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -114,48 +114,14 @@ ], "type": "string" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileSelectionParams": { "oneOf": [ { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "description": "Select a named built-in or user-defined profile. This updates profile identity metadata only; it does not replace the thread's effective permissions profile.", "properties": { "id": { "type": "string" }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, "type": { "enum": [ "profile" @@ -295,13 +261,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -576,7 +535,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. The server rejects this field because the thread permission profile value is not mutable through `turn/start`." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts index cbc8c6ef0a..73f9efcab5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type ActivePermissionProfile = { /** @@ -13,9 +12,4 @@ id: string, * Parent profile identifier once permissions profiles support * inheritance. This is currently always `null`. */ -extends: string | null, -/** - * Bounded user-requested modifications applied on top of the named - * profile, if any. - */ -modifications: Array, }; +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts deleted file mode 100644 index 1cbee6868a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts deleted file mode 100644 index c619edcea8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts index a415bd0028..c55fc29245 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts @@ -1,6 +1,5 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; -export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; +export type PermissionProfileSelectionParams = { "type": "profile", id: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index 5575701ff2..1715d7710e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { NetworkAccess } from "./NetworkAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 6076a4bb14..2facd2daaf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -23,7 +23,11 @@ model?: string | null, modelProvider?: string | null, serviceTier?: string | nul * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** +approvalsReviewer?: ApprovalsReviewer | null, /** + * Deprecated for fork. The server rejects this field because the source + * permission profile value is preserved across fork. + */ +sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** * Optional client-supplied analytics source classification for this forked thread. */ threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 6d1dbdca4f..cf343dc28c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -25,4 +25,8 @@ model?: string | null, modelProvider?: string | null, serviceTier?: string | nul * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; +approvalsReviewer?: ApprovalsReviewer | null, /** + * Deprecated for resume. The server rejects this field because the + * persisted permission profile value is preserved across resume. + */ +sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index b04919d86b..b2324294d1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -21,7 +21,8 @@ approvalPolicy?: AskForApproval | null, /** * subsequent turns. */ approvalsReviewer?: ApprovalsReviewer | null, /** - * Override the sandbox policy for this turn and subsequent turns. + * Deprecated for turns. The server rejects this field because the + * thread permission profile value is not mutable through `turn/start`. */ sandboxPolicy?: SandboxPolicy | null, /** * Override the model for this turn and subsequent turns. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index a6b961366e..fbdbd15a94 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; -export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; @@ -257,7 +256,6 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae00b08b73..5fcd3d024d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2272,6 +2272,7 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd, + workspace_roots: vec![], instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, @@ -2316,6 +2317,7 @@ mod tests { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), + "workspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], "approvalPolicy": "on-failure", "approvalsReviewer": "user", diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 86614a6aeb..0b6b74a61c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -5,7 +5,6 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -437,41 +436,6 @@ pub struct ActivePermissionProfile { /// inheritance. This is currently always `null`. #[serde(default)] pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } } impl From for ActivePermissionProfile { @@ -479,11 +443,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -493,11 +452,6 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } @@ -507,26 +461,12 @@ impl From for CoreActivePermissionProfile { #[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. + /// Select a named built-in or user-defined profile. This updates profile + /// identity metadata only; it does not replace the thread's effective + /// permissions profile. #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, + Profile { id: String }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -607,8 +547,6 @@ pub enum SandboxPolicy { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, #[serde(default)] network_access: bool, #[serde(default)] @@ -636,8 +574,8 @@ enum SandboxPolicyDeserialize { }, #[serde(rename_all = "camelCase")] WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, + #[serde(default, rename = "writableRoots")] + _writable_roots: Vec, #[serde(default)] read_only_access: Option, #[serde(default)] @@ -678,7 +616,7 @@ impl<'de> Deserialize<'de> for SandboxPolicy { Ok(SandboxPolicy::ExternalSandbox { network_access }) } SandboxPolicyDeserialize::WorkspaceWrite { - writable_roots, + _writable_roots: _, read_only_access, network_access, exclude_tmpdir_env_var, @@ -690,7 +628,6 @@ impl<'de> Deserialize<'de> for SandboxPolicy { )); } Ok(SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -720,12 +657,10 @@ impl SandboxPolicy { } } SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, @@ -752,12 +687,10 @@ impl From for SandboxPolicy { } } codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, 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 30599776ae..e873998644 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2019,7 +2019,6 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2029,7 +2028,6 @@ fn sandbox_policy_round_trips_workspace_write_access() { assert_eq!( core_policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2075,7 +2073,6 @@ fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { assert_eq!( policy, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path("/workspace")], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -3415,6 +3412,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { responsesapi_client_metadata: None, environments: None, cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 458722b3a2..3a80405cef 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -107,6 +107,11 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional workspace roots for this thread. Omitted uses the server's + /// configured roots, usually seeded from `cwd`. + #[experimental("thread/start.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -116,9 +121,8 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile selection for this new thread's initial permissions. + /// Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] #[ts(optional = nullable)] pub permissions: Option, @@ -195,6 +199,11 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/start.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -264,6 +273,11 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional replacement workspace roots for the resumed thread. Omitted + /// preserves the persisted or configured roots. + #[experimental("thread/resume.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -271,11 +285,13 @@ pub struct ThreadResumeParams { /// and subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// Deprecated for resume. The server rejects this field because the + /// persisted permission profile value is preserved across resume. #[ts(optional = nullable)] pub sandbox: Option, /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// with `sandbox`. This updates profile identity metadata without + /// replacing the effective permissions profile. #[experimental("thread/resume.permissions")] #[ts(optional = nullable)] pub permissions: Option, @@ -310,6 +326,11 @@ pub struct ThreadResumeResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/resume.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -370,6 +391,11 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional replacement workspace roots for the forked thread. Omitted + /// preserves the source thread roots when available. + #[experimental("thread/fork.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -377,11 +403,13 @@ pub struct ThreadForkParams { /// and subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// Deprecated for fork. The server rejects this field because the source + /// permission profile value is preserved across fork. #[ts(optional = nullable)] pub sandbox: Option, /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// `sandbox`. This updates profile identity metadata without replacing the + /// effective permissions profile. #[experimental("thread/fork.permissions")] #[ts(optional = nullable)] pub permissions: Option, @@ -419,6 +447,11 @@ pub struct ThreadForkResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/fork.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, 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 61a09bfbf5..4580e0f49d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -64,6 +64,11 @@ pub struct TurnStartParams { /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the workspace roots for this turn and subsequent turns. Omitted + /// preserves the current roots. + #[experimental("turn/start.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -72,13 +77,13 @@ pub struct TurnStartParams { /// subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, - /// Override the sandbox policy for this turn and subsequent turns. + /// Deprecated for turns. The server rejects this field because the + /// thread permission profile value is not mutable through `turn/start`. #[ts(optional = nullable)] pub sandbox_policy: Option, /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. + /// Cannot be combined with `sandboxPolicy`. This updates profile identity + /// metadata without replacing the effective permissions profile. #[experimental("turn/start.permissions")] #[ts(optional = nullable)] pub permissions: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c167627257..538dc32751 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -67,7 +67,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. -- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or experimental `permissions` profile selection, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, workspace roots, approval policy, approvals reviewer, and the active permission profile name. The permission profile value associated with the thread is not mutable through `turn/start`. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. @@ -122,8 +122,8 @@ 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"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. -- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. The stored permission profile value is preserved; clients may update `workspaceRoots` or use `permissions` to update the active profile name after validating that the profile id exists. The legacy `sandbox` shorthand is not accepted for resume. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Like resume, fork preserves the source permission profile value while allowing explicit `workspaceRoots` or active profile name updates. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. @@ -147,7 +147,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — 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. Prefer experimental `permissions` profile selection 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". +- `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. It can update `workspaceRoots` and use experimental `permissions` profile selection to update the active profile name, but it cannot change the thread's permission profile value; `sandboxPolicy` is rejected. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `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. 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"`. @@ -222,6 +222,7 @@ Start a fresh thread when you need a new Codex conversation. // current config settings. "model": "gpt-5.1-codex", "cwd": "/Users/me/project", + "workspaceRoots": ["/Users/me/project"], "approvalPolicy": "never", "sandbox": "workspaceWrite", // Prefer experimental profile selection: @@ -259,7 +260,7 @@ Start a fresh thread when you need a new Codex conversation. Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string. -To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. +To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can pass non-permission configuration overrides such as `approvalsReviewer`; the stored permission profile value is preserved unless you start a new thread. Use `workspaceRoots` to replace the thread root list, and use `permissions` only to update the active profile name after validating the id exists. By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. @@ -627,19 +628,15 @@ You can optionally specify config overrides on the new turn. If specified, these "input": [ { "type": "text", "text": "Run tests" } ], // Below are optional config overrides "cwd": "/Users/me/project", + "workspaceRoots": ["/Users/me/project", "/Users/me/project/packages/api"], // Experimental: turn-scoped environment selection. "environments": [ { "environmentId": "local", "cwd": "/Users/me/project" } ], "approvalPolicy": "unlessTrusted", - "sandboxPolicy": { - "type": "workspaceWrite", - "writableRoots": ["/Users/me/project"], - "networkAccess": true - }, - // Prefer experimental profile selection: + // Optional: validate and record the active profile name without changing + // the thread's permission profile value. // "permissions": { "type": "profile", "id": ":workspace" } - // Do not send both "sandboxPolicy" and "permissions". "model": "gpt-5.1-codex", "effort": "medium", "summary": "concise", diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 516e042301..5e243dcdd9 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -658,6 +658,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { }], responsesapi_client_metadata: None, cwd: None, + workspace_roots: None, approval_policy: None, sandbox_policy: None, permissions: None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 9de844c6cd..ba258d58b6 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,7 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; @@ -355,6 +354,8 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627..7c112abc82 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -200,7 +200,8 @@ impl CommandExecRequestProcessor { } else { ExecCapturePolicy::ShellTool }; - let sandbox_cwd = if permission_profile.is_some() { + let has_request_permission_profile = permission_profile.is_some(); + let sandbox_cwd = if has_request_permission_profile { cwd.clone() } else { self.config.cwd.clone() @@ -283,10 +284,16 @@ impl CommandExecRequestProcessor { None => None, }; + let workspace_roots = if has_request_permission_profile { + vec![sandbox_cwd.clone()] + } else { + self.config.workspace_roots.clone() + }; let exec_request = codex_core::exec::build_exec_request( exec_params, &effective_permission_profile, &sandbox_cwd, + &workspace_roots, &codex_linux_sandbox_exe, use_legacy_landlock, ) 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 45031490b0..5d8e929fec 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -600,11 +600,13 @@ pub(super) async fn handle_pending_thread_resume_request( permission_profile, active_permission_profile, cwd, + workspace_roots, reasoning_effort, .. } = pending.config_snapshot; let instruction_sources = pending.instruction_sources; - let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); + let sandbox = + thread_response_sandbox_policy(&permission_profile, &workspace_roots, 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(); @@ -620,6 +622,7 @@ pub(super) async fn handle_pending_thread_resume_request( approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, + workspace_roots, permission_profile: Some(permission_profile.into()), active_permission_profile, reasoning_effort, 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 dc0c169fab..3a5f6c7f5d 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -17,6 +17,102 @@ struct ThreadListFilters { use_state_db_only: bool, } +#[derive(Clone)] +struct PersistedThreadPermissionState { + permission_profile: PermissionProfile, + active_permission_profile: Option, + workspace_roots: Vec, +} + +fn absolute_path_from_history_path( + path: &Path, + base: Option<&AbsolutePathBuf>, +) -> Option { + if let Ok(path) = AbsolutePathBuf::try_from(path) { + Some(path) + } else if let Some(base) = base { + Some(AbsolutePathBuf::resolve_path_against_base( + path, + base.as_path(), + )) + } else { + AbsolutePathBuf::relative_to_current_dir(path).ok() + } +} + +fn roots_or_cwd( + roots: Vec, + cwd: Option<&AbsolutePathBuf>, +) -> Vec { + if roots.is_empty() { + cwd.cloned().into_iter().collect() + } else { + roots + } +} + +fn persisted_thread_permission_state( + history: &InitialHistory, + fallback_cwd: Option<&Path>, + fallback_sandbox_policy: Option<&codex_protocol::protocol::SandboxPolicy>, +) -> Option { + let mut cwd = + fallback_cwd.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None)); + let mut workspace_roots = None; + let mut permission_profile = None; + let mut active_permission_profile = None; + + for item in history.get_rollout_items() { + match item { + RolloutItem::SessionMeta(meta_line) => { + cwd = absolute_path_from_history_path(meta_line.meta.cwd.as_path(), cwd.as_ref()) + .or(cwd); + workspace_roots = Some(roots_or_cwd(meta_line.meta.workspace_roots, cwd.as_ref())); + } + RolloutItem::TurnContext(context) => { + cwd = absolute_path_from_history_path(context.cwd.as_path(), cwd.as_ref()).or(cwd); + workspace_roots = Some(roots_or_cwd(context.workspace_roots, cwd.as_ref())); + let context_cwd = cwd + .as_ref() + .map(AbsolutePathBuf::as_path) + .unwrap_or(context.cwd.as_path()); + permission_profile = Some(context.permission_profile.unwrap_or_else(|| { + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &context.sandbox_policy, + context_cwd, + ) + })); + if context.active_permission_profile.is_some() { + active_permission_profile = context.active_permission_profile; + } + } + RolloutItem::EventMsg(EventMsg::SessionConfigured(event)) => { + cwd = Some(event.cwd.clone()); + workspace_roots = Some(roots_or_cwd(event.workspace_roots, cwd.as_ref())); + permission_profile = Some(event.permission_profile); + active_permission_profile = event.active_permission_profile; + } + RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) => { + } + } + } + + if permission_profile.is_none() { + let cwd = cwd.as_ref()?; + let fallback_sandbox_policy = fallback_sandbox_policy?; + permission_profile = Some(PermissionProfile::from_legacy_sandbox_policy_for_cwd( + fallback_sandbox_policy, + cwd.as_path(), + )); + } + + Some(PersistedThreadPermissionState { + permission_profile: permission_profile?, + active_permission_profile, + workspace_roots: roots_or_cwd(workspace_roots.unwrap_or_default(), cwd.as_ref()), + }) +} + fn collect_resume_override_mismatches( request: &ThreadResumeParams, config_snapshot: &ThreadConfigSnapshot, @@ -98,12 +194,6 @@ fn collect_resume_override_mismatches( )); } } - if request.permissions.is_some() { - mismatch_details.push(format!( - "permissions override was provided and ignored while running; active={:?}", - config_snapshot.active_permission_profile - )); - } if let Some(requested_personality) = request.personality.as_ref() && config_snapshot.personality.as_ref() != Some(requested_personality) { @@ -802,6 +892,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -835,6 +926,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -1152,6 +1244,7 @@ impl ThreadRequestProcessor { let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, + &config_snapshot.workspace_roots, config_snapshot.cwd.as_path(), ); let active_permission_profile = @@ -1167,6 +1260,7 @@ impl ThreadRequestProcessor { approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, + workspace_roots: config_snapshot.workspace_roots, permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, @@ -1199,6 +1293,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -1212,6 +1307,8 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd: cwd.map(PathBuf::from), + workspace_roots: workspace_roots + .map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()), approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -1228,6 +1325,40 @@ impl ThreadRequestProcessor { overrides } + async fn validate_active_permission_profile_selection( + &self, + permissions: PermissionProfileSelectionParams, + request_overrides: Option>, + cwd: Option, + fallback_cwd: Option, + ) -> Result { + let mut overrides = ConfigOverrides { + cwd, + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(permissions)); + let config = self + .config_manager + .load_for_cwd(request_overrides, overrides, fallback_cwd) + .await + .map_err(|err| config_load_error(&err))?; + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid permission profile selection: {warning}" + ))); + } + config + .permissions + .active_permission_profile() + .ok_or_else(|| { + invalid_request("permission profile selection did not resolve to a named profile") + }) + } + fn parse_environment_selections( &self, environments: Option>, @@ -2329,6 +2460,15 @@ impl ThreadRequestProcessor { .await; return Ok(()); } + if params.sandbox.is_some() { + self.outgoing + .send_error( + request_id, + invalid_request("`sandbox` cannot be used to change thread permissions"), + ) + .await; + return Ok(()); + } if params.persist_extended_history { self.send_persist_extended_history_deprecation_notice(request_id.connection_id) .await; @@ -2366,9 +2506,10 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, - sandbox, + sandbox: _sandbox, permissions, config: mut request_overrides, base_instructions, @@ -2396,19 +2537,68 @@ impl ThreadRequestProcessor { }; let history_cwd = thread_history.session_cwd(); + let persisted_permission_state = persisted_thread_permission_state( + &thread_history, + history_cwd.as_deref(), + resume_source_thread + .as_ref() + .map(|thread| &thread.sandbox_policy), + ); + let active_permission_profile = if let Some(permissions) = permissions { + match self + .validate_active_permission_profile_selection( + permissions, + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await + { + Ok(active_permission_profile) => Some(active_permission_profile), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else { + persisted_permission_state + .as_ref() + .and_then(|state| state.active_permission_profile.clone()) + }; + let workspace_roots_were_explicit = workspace_roots.is_some(); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, - sandbox, - permissions, + /*sandbox*/ None, + /*permissions*/ None, base_instructions, developer_instructions, personality, ); + if let Some(persisted_permission_state) = persisted_permission_state { + typesafe_overrides.permission_profile = + Some(persisted_permission_state.permission_profile.clone()); + if !workspace_roots_were_explicit { + typesafe_overrides.workspace_roots = Some( + persisted_permission_state + .workspace_roots + .iter() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .collect(), + ); + } + } else if !workspace_roots_were_explicit + && let Some(root) = history_cwd + .as_deref() + .and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None)) + { + typesafe_overrides.workspace_roots = Some(vec![root.to_path_buf()]); + } self.load_and_apply_persisted_resume_metadata( &thread_history, &mut request_overrides, @@ -2417,7 +2607,7 @@ impl ThreadRequestProcessor { .await; // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match self + let mut config = match self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await @@ -2429,6 +2619,7 @@ impl ThreadRequestProcessor { return Ok(()); } }; + config.permissions.active_permission_profile = active_permission_profile; let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); @@ -2522,6 +2713,7 @@ impl ThreadRequestProcessor { let config_snapshot = codex_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, + &config_snapshot.workspace_roots, config_snapshot.cwd.as_path(), ); let active_permission_profile = thread_response_active_permission_profile( @@ -2538,6 +2730,7 @@ impl ThreadRequestProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, + workspace_roots: config_snapshot.workspace_roots, permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, @@ -2691,6 +2884,49 @@ impl ThreadRequestProcessor { ) .await?; + let active_permission_profile = if let Some(permissions) = params.permissions.clone() { + let config_snapshot = existing_thread.config_snapshot().await; + Some( + self.validate_active_permission_profile_selection( + permissions, + /*request_overrides*/ None, + /*cwd*/ None, + Some(config_snapshot.cwd.to_path_buf()), + ) + .await?, + ) + } else { + None + }; + if params.workspace_roots.is_some() || active_permission_profile.is_some() { + existing_thread + .update_turn_context_overrides(CodexThreadTurnContextOverrides { + cwd: None, + workspace_roots: params.workspace_roots.clone().map(|roots| { + roots + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .collect() + }), + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permission_profile: None, + active_permission_profile, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await + .map_err(|err| { + invalid_request(format!("invalid thread resume override: {err}")) + })?; + } + let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); if !mismatch_details.is_empty() { @@ -2996,6 +3232,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3014,6 +3251,11 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } + if sandbox.is_some() { + return Err(invalid_request( + "`sandbox` cannot be used to change thread permissions", + )); + } if persist_extended_history { self.send_persist_extended_history_deprecation_notice(request_id.connection_id) .await; @@ -3056,26 +3298,62 @@ impl ThreadRequestProcessor { } else { Some(cli_overrides) }; + let fork_history = InitialHistory::Forked(history_items.clone()); + let persisted_permission_state = persisted_thread_permission_state( + &fork_history, + history_cwd.as_deref(), + Some(&source_thread.sandbox_policy), + ) + .ok_or_else(|| { + invalid_request("thread history is missing persisted permission configuration") + })?; + let active_permission_profile = if let Some(permissions) = permissions { + Some( + self.validate_active_permission_profile_selection( + permissions, + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await?, + ) + } else { + persisted_permission_state.active_permission_profile.clone() + }; + let workspace_roots_were_explicit = workspace_roots.is_some(); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, - sandbox, - permissions, + /*sandbox*/ None, + /*permissions*/ None, base_instructions, developer_instructions, /*personality*/ None, ); + typesafe_overrides.permission_profile = + Some(persisted_permission_state.permission_profile.clone()); + if !workspace_roots_were_explicit { + typesafe_overrides.workspace_roots = Some( + persisted_permission_state + .workspace_roots + .iter() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .collect(), + ); + } typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = self + let mut config = self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await .map_err(|err| config_load_error(&err))?; + config.permissions.active_permission_profile = active_permission_profile; let fallback_model_provider = config.model_provider_id.clone(); let instruction_sources = Self::instruction_sources_from_config(&config).await; @@ -3179,6 +3457,7 @@ impl ThreadRequestProcessor { let config_snapshot = forked_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, + &config_snapshot.workspace_roots, config_snapshot.cwd.as_path(), ); let active_permission_profile = @@ -3194,6 +3473,7 @@ impl ThreadRequestProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, + workspace_roots: config_snapshot.workspace_roots, permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 61a31f9c4d..f4e4658818 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -495,6 +495,58 @@ mod thread_processor_behavior_tests { )); } + #[test] + fn persisted_thread_permission_state_uses_latest_turn_active_profile() { + let cwd = test_path_buf("/tmp/project").abs(); + let workspace_root = test_path_buf("/tmp/workspace").abs(); + let active_permission_profile = + codex_protocol::models::ActivePermissionProfile::new(":workspace"); + let permission_profile = codex_protocol::models::PermissionProfile::workspace_write(); + + let history = codex_protocol::protocol::InitialHistory::Forked(vec![ + codex_protocol::protocol::RolloutItem::TurnContext( + codex_protocol::protocol::TurnContextItem { + turn_id: Some("turn-1".to_string()), + trace_id: None, + cwd: cwd.to_path_buf(), + workspace_roots: vec![workspace_root.clone()], + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(permission_profile.clone()), + active_permission_profile: Some(active_permission_profile.clone()), + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }, + ), + ]); + + let persisted = persisted_thread_permission_state( + &history, + Some(cwd.as_path()), + /*fallback_sandbox_policy*/ None, + ) + .expect("permission state should be reconstructed"); + + assert_eq!(persisted.permission_profile, permission_profile); + assert_eq!( + persisted.active_permission_profile, + Some(active_permission_profile) + ); + assert_eq!(persisted.workspace_roots, vec![workspace_root]); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( @@ -630,6 +682,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some("priority".to_string())), cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -650,6 +703,7 @@ mod thread_processor_behavior_tests { permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, cwd, + workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, personality: None, 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 875bd3deaf..726f23fc24 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -179,30 +179,24 @@ pub(super) fn apply_permission_profile_selection_to_config_overrides( overrides: &mut ConfigOverrides, permissions: Option, ) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + let Some(PermissionProfileSelectionParams::Profile { id }) = permissions else { return; }; overrides.default_permissions = Some(id); - overrides - .additional_writable_roots - .extend(modifications.unwrap_or_default().into_iter().map( - |modification| match modification { - PermissionProfileModificationParams::AdditionalWritableRoot { path } => { - path.to_path_buf() - } - }, - )); } pub(super) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, + workspace_roots: &[AbsolutePathBuf], cwd: &Path, ) -> codex_app_server_protocol::SandboxPolicy { - let file_system_policy = permission_profile.file_system_sandbox_policy(); + let materialized_permission_profile = + permission_profile.materialize_project_roots_with_workspace_roots(workspace_roots); + let file_system_policy = materialized_permission_profile.file_system_sandbox_policy(); let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - permission_profile, + &materialized_permission_profile, &file_system_policy, - permission_profile.network_sandbox_policy(), + materialized_permission_profile.network_sandbox_policy(), cwd, ); sandbox_policy.into() 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 d1dae4ef46..cb521e8446 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -357,6 +357,7 @@ impl TurnRequestProcessor { let turn_has_input = !mapped_items.is_empty(); let has_any_overrides = params.cwd.is_some() + || params.workspace_roots.is_some() || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() @@ -373,52 +374,55 @@ impl TurnRequestProcessor { "`permissions` cannot be combined with `sandboxPolicy`", )); } + if params.sandbox_policy.is_some() { + return Err(invalid_request( + "`sandboxPolicy` cannot be used to change thread permissions", + )); + } let cwd = params.cwd; + let workspace_roots = params.workspace_roots; let approval_policy = params.approval_policy.map(AskForApproval::to_core); let approvals_reviewer = params .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core); let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile) = - if let Some(permissions) = params.permissions { - let snapshot = thread.config_snapshot().await; - let mut overrides = ConfigOverrides { - cwd: cwd.clone(), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permissions), - ); - let config = self - .config_manager - .load_for_cwd( - /*request_overrides*/ None, - overrides, - Some(snapshot.cwd.to_path_buf()), - ) - .await - .map_err(|err| config_load_error(&err))?; - // Startup config is allowed to fall back when requirements - // disallow a configured profile. An explicit turn request - // is different: reject it before accepting user input. - if let Some(warning) = config.startup_warnings.iter().find(|warning| { - warning.contains("Configured value for `permission_profile` is disallowed") - }) { - return Err(invalid_request(format!( - "invalid turn context override: {warning}" - ))); - } - ( - Some(config.permissions.permission_profile()), - config.permissions.active_permission_profile(), - ) - } else { - (None, None) + let active_permission_profile = if let Some(permissions) = params.permissions { + let snapshot = thread.config_snapshot().await; + let mut overrides = ConfigOverrides { + cwd: cwd.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() }; + apply_permission_profile_selection_to_config_overrides( + &mut overrides, + Some(permissions), + ); + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit turn request + // is different: reject it before accepting user input. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid turn context override: {warning}" + ))); + } + config.permissions.active_permission_profile() + } else { + None + }; + let permission_profile = None; let model = params.model; let effort = params.effort.map(Some); let summary = params.summary; @@ -432,6 +436,9 @@ impl TurnRequestProcessor { thread .validate_turn_context_overrides(CodexThreadTurnContextOverrides { cwd: cwd.clone(), + workspace_roots: workspace_roots + .clone() + .map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()), approval_policy, approvals_reviewer, sandbox_policy: sandbox_policy.clone(), @@ -457,6 +464,7 @@ impl TurnRequestProcessor { final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6b2a9a0abe..6c113297d7 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -135,6 +135,7 @@ pub fn create_fake_rollout_with_source( forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), + workspace_roots: Vec::new(), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, @@ -219,6 +220,7 @@ pub fn create_fake_rollout_with_text_elements( forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), + workspace_roots: Vec::new(), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 754d1f9467..e692ed95f2 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -132,6 +132,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + workspace_roots: Vec::new(), }, event_persistence_mode: ThreadEventPersistenceMode::default(), }) diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515ad..9254019d85 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -618,6 +618,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( approvals_reviewer: None, sandbox: None, permissions: None, + workspace_roots: None, config: None, service_name: None, base_instructions: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 80254d8f47..c8872c47bf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -610,6 +610,7 @@ sqlite = true codex_home: codex_home.path().to_path_buf(), sqlite_home: codex_home.path().to_path_buf(), cwd: codex_home.path().to_path_buf(), + workspace_roots: Vec::new(), model_provider_id: "mock_provider".to_string(), generate_memories: false, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 52420c0c80..c1fca44a44 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1288,6 +1288,7 @@ async fn seed_pathless_store_thread( dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { cwd: None, + workspace_roots: Vec::new(), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, }, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 36627ac6e7..3cb6c75e0d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -24,6 +24,7 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -1271,6 +1272,7 @@ stream_max_retries = 0 forked_from_id: None, timestamp: "2025-01-05T12:00:00Z".to_string(), cwd: repo_path.clone(), + workspace_roots: Vec::new(), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, @@ -2032,6 +2034,124 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R Ok(()) } +#[tokio::test] +async fn thread_resume_running_applies_workspace_roots_and_active_profile_name() -> Result<()> { + let server = responses::start_mock_server().await; + let first_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ])); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); + let _response_mock = + responses::mount_response_sequence(&server, vec![first_response, second_response]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace_root = codex_home.path().join("replacement-root"); + std::fs::create_dir_all(&workspace_root)?; + let workspace_root = AbsolutePathBuf::from_absolute_path(workspace_root.canonicalize()?)?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + workspace_roots: Some(vec![workspace_root.clone()]), + permissions: Some(PermissionProfileSelectionParams::Profile { + id: ":workspace".to_string(), + }), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + workspace_roots, + active_permission_profile, + .. + } = to_response::(resume_resp)?; + assert_eq!(workspace_roots, vec![workspace_root]); + assert_eq!( + active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()), + Some(":workspace") + ); + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 5b421dcec5..b851af545c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -218,6 +218,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + workspace_roots: Vec::new(), }, event_persistence_mode: ThreadEventPersistenceMode::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 524b795b81..312f959e82 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -329,10 +329,22 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( let warning: WarningNotification = serde_json::from_value(params).expect("deserialize warning notification"); assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str())); - assert_eq!( - warning.message, - "Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list." - ); + let warning_prefix = + "Exceeded skills context budget of 2%. All skill descriptions were removed and "; + let warning_suffix_plural = + " additional skills were not included in the model-visible skills list."; + let warning_suffix_singular = + " additional skill was not included in the model-visible skills list."; + let omitted_skill_count = warning + .message + .strip_prefix(warning_prefix) + .and_then(|tail| { + tail.strip_suffix(warning_suffix_plural) + .or_else(|| tail.strip_suffix(warning_suffix_singular)) + }) + .and_then(|count| count.parse::().ok()) + .expect("warning should report omitted skill count"); + assert!(omitted_skill_count > 0); timeout( DEFAULT_READ_TIMEOUT, @@ -781,7 +793,6 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() }], permissions: Some(PermissionProfileSelectionParams::Profile { id: ":danger-no-sandbox".to_string(), - modifications: None, }), ..Default::default() }) @@ -1657,7 +1668,6 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { text_elements: Vec::new(), }], approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1825,7 +1835,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_updates_cwd_without_replacing_workspace_roots_v2() -> Result<()> { skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -1879,7 +1889,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - // first turn with workspace-write sandbox and first_cwd + // first turn with first_cwd as the thread's workspace root let first_turn = mcp .send_turn_start_request(TurnStartParams { environments: None, @@ -1890,14 +1900,10 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(first_cwd.clone()), + workspace_roots: Some(vec![first_cwd.try_into()?]), approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![first_cwd.try_into()?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }), + sandbox_policy: None, permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), @@ -1920,7 +1926,8 @@ 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 + // second turn changes cwd only; workspace roots stay on first_cwd while + // exec begins in second_cwd. let second_turn = mcp .send_turn_start_request(TurnStartParams { environments: None, @@ -1931,9 +1938,10 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(second_cwd.clone()), + 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), @@ -3306,7 +3314,6 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { text: "run a command".to_string(), text_elements: Vec::new(), }], - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), ..Default::default() }) .await?; @@ -3402,7 +3409,7 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { } #[tokio::test] -async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> Result<()> { +async fn turn_start_rejects_sandbox_policy_and_does_not_persist_project_trust() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; let server = create_mock_responses_server_sequence_unchecked(responses).await; @@ -3444,16 +3451,25 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R ..Default::default() }) .await?; - timeout( + let err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_request)), + mcp.read_stream_until_error_message(RequestId::Integer(turn_request)), ) .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.error.message, + "`sandboxPolicy` cannot be used to change thread permissions" + ); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), ) - .await??; + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected sandboxPolicy" + ); let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config_toml.contains("trust_level = \"trusted\"")); diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 31247418e5..c20eb58883 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -104,6 +104,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), + sandbox: Some(codex_app_server_protocol::SandboxMode::DangerFullAccess), ..Default::default() }) .await?; @@ -123,7 +124,6 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { }], cwd: Some(workspace.clone()), approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), @@ -515,6 +515,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), + sandbox: Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite), ..Default::default() }) .await?; @@ -533,13 +534,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() text_elements: Vec::new(), }], cwd: Some(workspace.clone()), + workspace_roots: Some(vec![workspace.clone().try_into()?]), approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![workspace.clone().try_into()?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }), + sandbox_policy: None, model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index e9bc6a046e..c0909e93a9 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -762,6 +762,16 @@ mod tests { Ok(()) } + fn workspace_write_policy_for_codex_home( + codex_home: &TempDir, + ) -> codex_protocol::permissions::FileSystemSandboxPolicy { + let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")) + .expect("codex home tempdir should be absolute"); + codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy() + .with_additional_legacy_workspace_writable_roots(std::slice::from_ref(&memories_root)) + } + #[tokio::test] async fn debug_sandbox_honors_active_permission_profiles() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -940,8 +950,7 @@ mod tests { assert_eq!( config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + workspace_write_policy_for_codex_home(&codex_home) ); Ok(()) @@ -973,8 +982,7 @@ mod tests { assert_eq!( config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + workspace_write_policy_for_codex_home(&codex_home) ); Ok(()) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 59d1cde9ea..cc3f33b11b 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1995,7 +1995,6 @@ allowed_approvals_reviewers = ["user"] let config: ConfigRequirementsToml = from_str(toml_str)?; let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; assert!( requirements .permission_profile @@ -2005,7 +2004,6 @@ allowed_approvals_reviewers = ["user"] .is_ok() ); let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2116,9 +2114,7 @@ allowed_approvals_reviewers = ["user"] ); let requirements = ConfigRequirements::try_from(requirements_with_sources)?; - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 989aab1691..0285f85349 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -757,7 +757,7 @@ impl ConfigToml { SandboxMode::ReadOnly => PermissionProfile::read_only(), SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -768,7 +768,7 @@ impl ConfigToml { NetworkSandboxPolicy::Restricted }; PermissionProfile::workspace_write_with( - writable_roots, + &[], network_policy, *exclude_tmpdir_env_var, *exclude_slash_tmp, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 079ee61f01..0dddf9100f 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -64,6 +64,12 @@ pub(crate) struct LiveAgent { pub(crate) status: AgentStatus, } +#[derive(Clone, Copy)] +enum LiveAgentShutdownMode { + SubmitOnly, + WaitForTermination, +} + #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub(crate) struct ListedAgent { pub(crate) agent_name: String, @@ -233,32 +239,31 @@ impl AgentControl { // The same `AgentControl` is sent to spawn the thread. let new_thread = match (session_source, options.fork_mode.as_ref()) { (Some(session_source), Some(_)) => { - self.spawn_forked_thread( + Box::pin(self.spawn_forked_thread( &state, config, session_source, &options, inherited_shell_snapshot, inherited_exec_policy, - ) + )) .await? } (Some(session_source), None) => { - state - .spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - /*thread_source*/ Some(ThreadSource::Subagent), - /*persist_extended_history*/ false, - /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await? + Box::pin(state.spawn_new_thread_with_source( + config, + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + )) + .await? } - (None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?, + (None, _) => Box::pin(state.spawn_new_thread(config, self.clone())).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); @@ -713,22 +718,42 @@ impl AgentControl { /// Submit a shutdown request for a live agent without marking it explicitly closed in /// persisted spawn-edge state. pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { + self.shutdown_live_agent_with_mode(agent_id, LiveAgentShutdownMode::SubmitOnly) + .await + } + + async fn shutdown_live_agent_and_wait(&self, agent_id: ThreadId) -> CodexResult { + self.shutdown_live_agent_with_mode(agent_id, LiveAgentShutdownMode::WaitForTermination) + .await + } + + async fn shutdown_live_agent_with_mode( + &self, + agent_id: ThreadId, + mode: LiveAgentShutdownMode, + ) -> CodexResult { let state = self.upgrade()?; + let mut thread_to_wait = None; let result = if let Ok(thread) = state.get_thread(agent_id).await { thread.codex.session.ensure_rollout_materialized().await; thread.codex.session.flush_rollout().await?; - let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { + if matches!(thread.agent_status().await, AgentStatus::Shutdown) { Ok(String::new()) } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - thread.wait_until_terminated().await; - result + let result = state.send_op(agent_id, Op::Shutdown {}).await; + if result.is_ok() && matches!(mode, LiveAgentShutdownMode::WaitForTermination) { + thread_to_wait = Some(thread); + } + result + } } else { state.send_op(agent_id, Op::Shutdown {}).await }; let _ = state.remove_thread(&agent_id).await; self.state.release_spawned_thread(agent_id); + if let Some(thread) = thread_to_wait { + thread.wait_until_terminated().await; + } result } @@ -750,9 +775,9 @@ impl AgentControl { /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; - let result = self.shutdown_live_agent(agent_id).await; + let result = self.shutdown_live_agent_and_wait(agent_id).await; for descendant_id in descendant_ids { - match self.shutdown_live_agent(descendant_id).await { + match self.shutdown_live_agent_and_wait(descendant_id).await { Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} Err(err) => return Err(err), } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index a0dd95c37b..75330323cd 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -58,6 +58,7 @@ pub struct ThreadConfigSnapshot { pub permission_profile: PermissionProfile, pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, pub personality: Option, @@ -67,11 +68,15 @@ pub struct ThreadConfigSnapshot { impl ThreadConfigSnapshot { pub fn sandbox_policy(&self) -> SandboxPolicy { - let file_system_sandbox_policy = self.permission_profile.file_system_sandbox_policy(); + let permission_profile = self + .permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - &self.permission_profile, + &permission_profile, &file_system_sandbox_policy, - self.permission_profile.network_sandbox_policy(), + permission_profile.network_sandbox_policy(), self.cwd.as_path(), ) } @@ -81,6 +86,7 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadTurnContextOverrides { pub cwd: Option, + pub workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_policy: Option, @@ -235,8 +241,26 @@ impl CodexThread { &self, overrides: CodexThreadTurnContextOverrides, ) -> ConstraintResult<()> { + let updates = self.turn_context_updates_from_overrides(overrides).await; + self.codex.session.validate_settings(&updates).await + } + + /// Apply persistent thread context overrides immediately. + pub async fn update_turn_context_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> ConstraintResult<()> { + let updates = self.turn_context_updates_from_overrides(overrides).await; + self.codex.session.update_settings(updates).await + } + + async fn turn_context_updates_from_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> SessionSettingsUpdate { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -260,8 +284,9 @@ impl CodexThread { .with_updates(model, effort, /*developer_instructions*/ None) }; - let updates = SessionSettingsUpdate { + SessionSettingsUpdate { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -273,8 +298,7 @@ impl CodexThread { service_tier, personality, ..Default::default() - }; - self.codex.session.validate_settings(&updates).await + } } /// Use sparingly: this is intended to be removed soon. diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 4a7a33b7e6..e16b7d89ea 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -527,9 +527,10 @@ writable_roots = ["~/code"] let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?; match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { assert_eq!( - writable_roots + config + .workspace_roots .iter() .filter(|root| **root == expected_root) .count(), @@ -593,7 +594,6 @@ allowed_sandbox_modes = ["read-only"] .permission_profile .can_set(&PermissionProfile::from_legacy_sandbox_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e7f374a653..f2be3baa17 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -67,7 +67,6 @@ use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; @@ -96,6 +95,14 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; +fn materialized_file_system_sandbox_policy(config: &Config) -> FileSystemSandboxPolicy { + config + .permissions + .permission_profile() + .materialize_project_roots_with_workspace_roots(&config.workspace_roots) + .file_system_sandbox_policy() +} + fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -1008,7 +1015,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: }, FileSystemSandboxEntry { path: FileSystemPath::Path { - path: memories_root.clone(), + path: memories_root, }, access: FileSystemAccessMode::Write, }, @@ -1017,7 +1024,6 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1194,7 +1200,6 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1395,7 +1400,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config .permissions @@ -1416,7 +1421,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl } #[tokio::test] -async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() +async fn default_permissions_read_only_records_additional_writable_roots_as_workspace_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -1437,18 +1442,18 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); - assert!( - policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), - "expected additional writable root to modify :read-only, policy: {policy:?}" + let policy = materialized_file_system_sandbox_policy(&config); + assert_eq!( + policy, + PermissionProfile::read_only().file_system_sandbox_policy() + ); + assert_eq!( + config.workspace_roots, + vec![cwd.path().abs(), extra_root.clone()] ); assert_eq!( config.permissions.active_permission_profile(), - Some( - ActivePermissionProfile::new(":read-only").with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { path: extra_root }, - ]) - ) + Some(ActivePermissionProfile::new(":read-only")) ); Ok(()) } @@ -1479,7 +1484,7 @@ async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_setti ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted @@ -1519,7 +1524,7 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config .permissions @@ -1587,7 +1592,7 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert!( policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), "expected implicit :workspace to preserve sandbox_workspace_write.writable_roots, policy: {policy:?}" @@ -1604,12 +1609,11 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se ); match config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => { - assert!(writable_roots.contains(&extra_root)); + assert!(config.workspace_roots.contains(&extra_root)); assert!(network_access); assert!(exclude_tmpdir_env_var); assert!(!exclude_slash_tmp); @@ -1653,7 +1657,7 @@ async fn implicit_builtin_workspace_profile_preserves_add_dir_metadata_carveouts ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); let extra_root = extra_root.path().abs(); assert!( policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), @@ -1828,9 +1832,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() ) .await?; - let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( - codex_home.path().join("memories"), - )?)?; assert!( config .permissions @@ -1840,7 +1841,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![external_write_path, memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2450,7 +2450,6 @@ trust_level = "trusted" assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root.clone()], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2490,7 +2489,6 @@ exclude_slash_tmp = true assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2554,13 +2552,21 @@ exclude_slash_tmp = true NetworkSandboxPolicy::from(&sandbox_policy), "case `{name}` should preserve network semantics from legacy config" ); - assert_eq!( - file_system_policy - .to_legacy_sandbox_policy(network_policy, cwd.path()) - .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), - sandbox_policy, - "case `{name}` should preserve its legacy compatibility projection" - ); + let direct_legacy_projection = + file_system_policy.to_legacy_sandbox_policy(network_policy, cwd.path()); + if name == "workspace-write" && !cfg!(target_os = "windows") { + assert!( + direct_legacy_projection.is_err(), + "case `{name}` should require the compatibility projection for split workspace roots" + ); + } else { + assert_eq!( + direct_legacy_projection + .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), + sandbox_policy, + "case `{name}` should preserve its legacy compatibility projection" + ); + } match name.as_str() { "danger-full-access" | "read-only" => { @@ -2602,14 +2608,19 @@ exclude_slash_tmp = true }) ); assert!( - file_system_policy + config.workspace_roots.contains(&extra_root), + "case `{name}` should store legacy writable roots as thread workspace roots" + ); + assert!( + materialized_file_system_sandbox_policy(&config) .entries .contains(&FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra_root.clone(), }, access: FileSystemAccessMode::Write, - }) + }), + "case `{name}` should materialize workspace roots into runtime write access" ); for subpath in [".git", ".agents", ".codex"] { assert!( @@ -3352,14 +3363,15 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< } } else { match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { assert_eq!( - writable_roots + config + .workspace_roots .iter() .filter(|root| **root == expected_backend) .count(), 1, - "expected single writable root entry for {}", + "expected single workspace root entry for {}", expected_backend.display() ); } @@ -3419,13 +3431,16 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result "expected memories root directory to exist at {}", memories_root.display() ); - let expected_memories_root = memories_root.abs(); + let expected_memories_root = + AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(&memories_root)?)?; match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { + let writable_roots = materialized_file_system_sandbox_policy(&config) + .get_writable_roots_with_cwd(config.cwd.as_path()); assert_eq!( writable_roots .iter() - .filter(|root| **root == expected_memories_root) + .filter(|root| root.root == expected_memories_root) .count(), 1, "expected single writable root entry for {}", @@ -6843,6 +6858,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7287,6 +7303,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7445,6 +7462,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7588,6 +7606,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c68381abd5..f6aa76d8a8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -87,7 +87,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -564,6 +563,10 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute roots that define the writable project/workspace set for + /// symbolic `:project_roots` permission entries. + pub workspace_roots: Vec, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -1835,6 +1838,11 @@ fn apply_managed_filesystem_constraints( } } +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -1863,6 +1871,9 @@ pub struct ConfigOverrides { pub ephemeral: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Explicit workspace roots for this session. When set, this is the full + /// root list rather than an additive override. + pub workspace_roots: Option>, } /// Resolves the OSS provider from CLI override, profile config, or global config. @@ -2137,6 +2148,7 @@ impl Config { tools_web_search_request: override_tools_web_search_request, ephemeral, additional_writable_roots, + workspace_roots: workspace_roots_override, } = overrides; if sandbox_mode.is_some() && permission_profile.is_some() { @@ -2224,11 +2236,10 @@ impl Config { } } }))?; - let mut additional_writable_roots: Vec = additional_writable_roots + let requested_additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); - let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -2270,12 +2281,7 @@ impl Config { }; let memories_root = memory_root(&codex_home); std::fs::create_dir_all(&memories_root)?; - if !additional_writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - additional_writable_roots.push(memories_root); - } + let internal_writable_roots = vec![memories_root]; let profiles_are_active = default_permissions_override.is_some() || matches!( @@ -2285,6 +2291,31 @@ impl Config { || permission_config_syntax.is_none(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); + let should_seed_legacy_workspace_roots = + default_permissions.is_none() + && matches!( + permission_config_syntax, + None | Some(PermissionConfigSyntax::Legacy) + ); + let mut workspace_roots = match workspace_roots_override { + Some(workspace_roots) => workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) + }) + .collect(), + None => { + let mut workspace_roots = vec![resolved_cwd.clone()]; + workspace_roots.extend(requested_additional_writable_roots.clone()); + if should_seed_legacy_workspace_roots + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + workspace_roots + } + }; + dedupe_absolute_paths(&mut workspace_roots); let ( configured_network_proxy_config, permission_profile, @@ -2321,10 +2352,7 @@ impl Config { ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2375,28 +2403,8 @@ impl Config { resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = if using_implicit_builtin_profile { - file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots( - &additional_writable_roots, - ) - } else { - file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ) - }; - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } else if matches!(permission_profile, PermissionProfile::Managed { .. }) - && !requested_additional_writable_roots.is_empty() - { - file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &requested_additional_writable_roots, - ); + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, network_sandbox_policy, @@ -2413,22 +2421,7 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - let active_permission_profile = if !requested_additional_writable_roots.is_empty() - && matches!(permission_profile, PermissionProfile::Managed { .. }) - { - ActivePermissionProfile::new(default_permissions).with_modifications( - requested_additional_writable_roots - .iter() - .cloned() - .map(|path| { - ActivePermissionProfileModification::AdditionalWritableRoot { path } - }) - .collect(), - ) - } else { - ActivePermissionProfile::new(default_permissions) - }; - Some(active_permission_profile) + Some(ActivePermissionProfile::new(default_permissions)) }; ( configured_network_proxy_config, @@ -2467,25 +2460,25 @@ impl Config { } let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - // `additional_writable_roots` is a legacy workspace-write knob. It - // only applies when the derived managed profile has workspace-style - // write access to the project roots; read-only, disabled, external, - // and future non-workspace profiles must not silently grow extra - // write access. + // Internal writable roots only apply when the derived managed + // profile has workspace-style write access to the project roots; + // read-only, disabled, external, and future non-workspace profiles + // must not silently grow extra write access. + let materialized_file_system_sandbox_policy = permission_profile + .materialize_project_roots_with_workspace_roots(&workspace_roots) + .file_system_sandbox_policy(); if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && file_system_sandbox_policy.can_write_path_with_cwd( + && materialized_file_system_sandbox_policy.can_write_path_with_cwd( resolved_cwd.as_path(), resolved_cwd.as_path(), ) - && !file_system_sandbox_policy.has_full_disk_write_access() + && !materialized_file_system_sandbox_policy.has_full_disk_write_access() { - // Keep legacy behavior for extra writable roots while storing - // the result as the canonical permission profile. Explicit - // extra roots are concrete paths, so their metadata carveouts - // are also concrete rather than symbolic `:project_roots` - // entries. + // Keep Codex runtime write access while storing the result as + // the canonical permission profile. Workspace roots themselves + // are held separately on the thread. file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2969,6 +2962,7 @@ impl Config { model_provider_id, model_provider, cwd: resolved_cwd, + workspace_roots, startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b51a8973a3..cb1bf2e52e 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -59,12 +59,12 @@ pub(crate) fn builtin_permission_profile( BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, }) => PermissionProfile::workspace_write_with( - writable_roots, + &[], if *network_access { NetworkSandboxPolicy::Enabled } else { diff --git a/codex-rs/core/src/context/permissions_instructions_tests.rs b/codex-rs/core/src/context/permissions_instructions_tests.rs index 16d5dc631a..97948ad5ad 100644 --- a/codex-rs/core/src/context/permissions_instructions_tests.rs +++ b/codex-rs/core/src/context/permissions_instructions_tests.rs @@ -56,7 +56,6 @@ fn builds_permissions_with_network_access_override() { #[test] fn builds_permissions_from_policy() { let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 74f4d29bfb..bb66fabf5d 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -123,11 +123,13 @@ fn reference_context_item() -> TurnContextItem { turn_id: Some("reference-turn".to_string()), trace_id: None, cwd: PathBuf::from("/tmp/reference-cwd"), + workspace_roots: Vec::new(), current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-test".to_string(), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 1bc2cb0895..626630916c 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -49,7 +49,14 @@ fn build_permissions_update_item( } let prev = previous?; - if prev.permission_profile() == next.permission_profile() + let prev_permission_profile = prev + .permission_profile() + .materialize_project_roots_with_workspace_roots(&prev.workspace_roots); + let next_permission_profile = next + .permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&next.workspace_roots); + if prev_permission_profile == next_permission_profile && prev.approval_policy == next.approval_policy.value() { return None; @@ -57,7 +64,7 @@ fn build_permissions_update_item( Some( PermissionsInstructions::from_permission_profile( - &next.permission_profile, + &next_permission_profile, next.approval_policy.value(), next.config.approvals_reviewer, exec_policy, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index fd5cd7bcdc..c01537e895 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -294,6 +294,7 @@ pub async fn process_exec_tool_call( params: ExecParams, permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], codex_linux_sandbox_exe: &Option, use_legacy_landlock: bool, stdout_stream: Option, @@ -302,6 +303,7 @@ pub async fn process_exec_tool_call( params, permission_profile, sandbox_cwd, + workspace_roots, codex_linux_sandbox_exe, use_legacy_landlock, )?; @@ -316,6 +318,7 @@ pub fn build_exec_request( params: ExecParams, permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], codex_linux_sandbox_exe: &Option, use_legacy_landlock: bool, ) -> Result { @@ -377,6 +380,7 @@ pub fn build_exec_request( enforce_managed_network, network: network.as_ref(), sandbox_policy_cwd: sandbox_cwd, + workspace_roots, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_deref(), use_legacy_landlock, windows_sandbox_level, diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 9d335a81c7..f875fc5f34 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -365,6 +365,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result }, &permission_profile, &cwd, + &[], &None, /*use_legacy_landlock*/ false, /*stdout_stream*/ None, @@ -470,7 +471,6 @@ fn windows_restricted_token_allows_legacy_restricted_policies() { #[test] fn windows_restricted_token_allows_legacy_workspace_write_policies() { let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -528,7 +528,6 @@ fn windows_restricted_token_rejects_split_only_filesystem_policies() { let docs = temp_dir.path().join("docs"); std::fs::create_dir_all(&docs).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -573,7 +572,6 @@ fn windows_restricted_token_rejects_root_write_read_only_carveouts() { let docs = temp_dir.path().join("docs"); std::fs::create_dir_all(&docs).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -619,7 +617,6 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { let docs = cwd.join("docs"); std::fs::create_dir_all(docs.as_path()).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -711,7 +708,6 @@ fn windows_elevated_supports_split_write_read_carveouts() { std::fs::create_dir_all(&docs).expect("create docs"); let expected_docs = dunce::canonicalize(&docs).expect("canonical docs"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -767,7 +763,6 @@ fn windows_elevated_rejects_unreadable_split_carveouts() { let blocked = temp_dir.path().join("blocked"); std::fs::create_dir_all(&blocked).expect("create blocked"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -816,7 +811,6 @@ fn windows_elevated_rejects_unreadable_split_carveouts() { fn windows_elevated_rejects_unreadable_globs() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -867,7 +861,6 @@ fn windows_elevated_rejects_reopened_writable_descendants() { let nested = docs.join("nested"); std::fs::create_dir_all(&nested).expect("create nested"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1040,6 +1033,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { params, &PermissionProfile::Disabled, &cwd, + &[], &None, /*use_legacy_landlock*/ false, /*stdout_stream*/ None, diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 699e06fe67..7bc0ea82f8 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -47,6 +47,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R forked_from_id: None, timestamp: TEST_TIMESTAMP.to_string(), cwd: std::path::PathBuf::from("."), + workspace_roots: Vec::new(), originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index e0d268dc2d..5b855c9cfc 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -37,6 +37,10 @@ impl codex_rollout::RolloutConfigView for Config { self.cwd.as_path() } + fn workspace_roots(&self) -> &[codex_utils_absolute_path::AbsolutePathBuf] { + &self.workspace_roots + } + fn model_provider_id(&self) -> &str { self.model_provider_id.as_str() } diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index d699172498..15849568a7 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -33,7 +33,6 @@ fn test_writable_roots_constraint() { // Policy limited to the workspace only; exclude system temp roots so // only `cwd` is writable by default. let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -51,17 +50,16 @@ fn test_writable_roots_constraint() { &cwd, )); - // With the parent dir explicitly added as a writable root, the - // outside write should be permitted. - let policy_with_parent = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![parent], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + // With the parent dir explicitly added as a workspace root, the outside + // write should be permitted. + let file_system_policy_with_parent = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { path: parent }, + access: FileSystemAccessMode::Write, + }]); assert!(is_write_patch_constrained_to_writable_paths( &add_outside, - &FileSystemSandboxPolicy::from(&policy_with_parent), + &file_system_policy_with_parent, &cwd, )); } @@ -101,7 +99,6 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { let outside_path = parent.join("outside.txt"); let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -145,7 +142,6 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { let outside_path = parent.join("outside.txt"); let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -295,7 +291,6 @@ fn missing_project_dot_codex_config_requires_approval() { let config_path = cwd.join(".codex").join("config.toml"); let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string()); let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 03f29bac53..6ff8785c76 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -143,6 +143,7 @@ pub(super) async fn user_input_or_turn_inner( items, SessionSettingsUpdate { cwd: Some(cwd), + workspace_roots: None, approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), @@ -163,6 +164,7 @@ pub(super) async fn user_input_or_turn_inner( } Op::UserInputWithTurnContext { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -195,6 +197,8 @@ pub(super) async fn user_input_or_turn_inner( items, SessionSettingsUpdate { cwd, + workspace_roots: workspace_roots + .map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()), approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index af36fda487..88ee867517 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -619,6 +619,7 @@ impl Codex { active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: environment_selections.to_selections(), @@ -2599,9 +2600,13 @@ impl Session { developer_sections.push(model_switch_message); } if turn_context.config.include_permissions_instructions { + let permission_profile = turn_context + .permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&turn_context.workspace_roots); developer_sections.push( PermissionsInstructions::from_permission_profile( - &turn_context.permission_profile, + &permission_profile, turn_context.approval_policy.value(), turn_context.config.approvals_reviewer, self.services.exec_policy.current().as_ref(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 7d4b1b736a..ca16f0f70c 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -143,6 +143,7 @@ pub(super) async fn spawn_review_thread( windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), + workspace_roots: parent_turn_context.workspace_roots.clone(), final_output_json_schema: None, codex_self_exe: parent_turn_context.codex_self_exe.clone(), codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 5cfcc38053..cc45084dc4 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -61,11 +61,13 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -102,11 +104,13 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -912,11 +916,13 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -990,11 +996,13 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1021,11 +1029,13 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1136,11 +1146,13 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: current_model.to_string(), @@ -1250,11 +1262,13 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1402,11 +1416,13 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 8c9ea1a123..2cd15a40d1 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -75,6 +75,9 @@ pub(crate) struct SessionConfiguration { /// execution sandbox are resolved against this directory **instead** of /// the process-wide current working directory. pub(super) cwd: AbsolutePathBuf, + /// Workspace roots used to materialize symbolic `:project_roots` + /// permission entries for this thread. + 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. @@ -112,12 +115,13 @@ impl SessionConfiguration { } pub(super) fn sandbox_policy(&self) -> SandboxPolicy { - self.permission_profile() + let permission_profile = self.materialized_permission_profile(); + permission_profile .to_legacy_sandbox_policy(&self.cwd) .unwrap_or_else(|_| { let file_system_sandbox_policy = self.file_system_sandbox_policy(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - self.permission_profile.get(), + &permission_profile, &file_system_sandbox_policy, self.network_sandbox_policy(), &self.cwd, @@ -126,13 +130,21 @@ impl SessionConfiguration { } pub(super) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.get().file_system_sandbox_policy() + self.materialized_permission_profile() + .file_system_sandbox_policy() } pub(super) fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { self.permission_profile.get().network_sandbox_policy() } + fn materialized_permission_profile(&self) -> PermissionProfile { + self.permission_profile + .get() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + } + pub(super) fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), @@ -143,6 +155,7 @@ impl SessionConfiguration { permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, @@ -223,6 +236,20 @@ impl SessionConfiguration { let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; + if let Some(workspace_roots) = updates.workspace_roots.clone() { + let mut workspace_roots: Vec = workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base( + normalize_for_native_workdir(path.as_path()), + next_configuration.cwd.as_path(), + ) + }) + .collect(); + dedupe_absolute_paths(&mut workspace_roots); + next_configuration.workspace_roots = workspace_roots; + } + if let Some(permission_profile) = updates.permission_profile.clone() { let active_permission_profile = updates.active_permission_profile.clone().or_else(|| { @@ -273,6 +300,8 @@ impl SessionConfiguration { current_network_sandbox_policy, ), )?; + } else if let Some(active_permission_profile) = updates.active_permission_profile.clone() { + next_configuration.active_permission_profile = Some(active_permission_profile); } if let Some(app_server_client_name) = updates.app_server_client_name.clone() { next_configuration.app_server_client_name = Some(app_server_client_name); @@ -306,9 +335,15 @@ impl SessionConfiguration { } } +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { pub(crate) cwd: Option, + pub(crate) workspace_roots: Option>, pub(crate) approval_policy: Option, pub(crate) approvals_reviewer: Option, pub(crate) sandbox_policy: Option, @@ -425,6 +460,7 @@ impl Session { dynamic_tools: session_configuration.dynamic_tools.clone(), metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), + workspace_roots: config.workspace_roots.clone(), model_provider: config.model_provider_id.clone(), memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled @@ -447,6 +483,7 @@ impl Session { include_archived: true, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), + workspace_roots: config.workspace_roots.clone(), model_provider: config.model_provider_id.clone(), memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled @@ -933,6 +970,7 @@ impl Session { permission_profile: session_configuration.permission_profile(), active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd.clone(), + workspace_roots: session_configuration.workspace_roots.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), initial_messages, network_proxy: session_network_proxy.filter(|_| { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6511ce7a76..4b3ef8170d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1977,11 +1977,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + workspace_roots: turn_context.workspace_roots.clone(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -2582,6 +2584,7 @@ async fn set_rate_limits_retains_previous_credits() { active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -2686,6 +2689,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -2952,6 +2956,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), + workspace_roots: config.workspace_roots.clone(), model_provider: config.model_provider_id.clone(), memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled @@ -3163,6 +3168,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3205,7 +3211,6 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd session_configuration.cwd = original_cwd.abs(); let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -3239,9 +3244,15 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd .expect("cwd-only update should succeed"); assert_eq!( - updated.file_system_sandbox_policy(), + updated.permission_profile().file_system_sandbox_policy(), file_system_sandbox_policy ); + let expected_materialized_file_system_policy = file_system_sandbox_policy + .materialize_project_roots_with_workspace_roots(&updated.workspace_roots); + assert_eq!( + updated.file_system_sandbox_policy(), + expected_materialized_file_system_policy + ); } #[tokio::test] @@ -3291,9 +3302,16 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ expected_file_system_policy.glob_scan_max_depth = Some(2); expected_file_system_policy.entries.push(deny_entry); assert_eq!( - updated.file_system_sandbox_policy(), + updated.permission_profile().file_system_sandbox_policy(), expected_file_system_policy ); + let expected_materialized_file_system_policy = expected_file_system_policy + .clone() + .materialize_project_roots_with_workspace_roots(&updated.workspace_roots); + assert_eq!( + updated.file_system_sandbox_policy(), + expected_materialized_file_system_policy + ); } #[tokio::test] @@ -3310,7 +3328,7 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { - path: external_write_path.clone(), + path: external_write_path, }, access: FileSystemAccessMode::Write, }]); @@ -3334,7 +3352,6 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo assert_eq!( updated.sandbox_policy(), SandboxPolicy::WorkspaceWrite { - writable_roots: vec![external_write_path], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -3429,14 +3446,15 @@ enabled = false } #[tokio::test] -async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_update() { +async fn session_configuration_apply_preserves_legacy_workspace_roots_on_cwd_update() { let mut session_configuration = make_session_configuration_for_tests().await; let workspace = tempfile::tempdir().expect("create temp dir"); let project_root = workspace.path().join("project"); let original_cwd = project_root.join("subdir"); - session_configuration.cwd = original_cwd.abs(); + let original_cwd = original_cwd.abs(); + session_configuration.cwd = original_cwd.clone(); + session_configuration.workspace_roots = vec![original_cwd.clone()]; let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -3455,20 +3473,19 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.clone()), + cwd: Some(project_root), ..Default::default() }) .expect("cwd-only update should succeed"); - let expected_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &updated.sandbox_policy(), - &project_root, - ); + let expected_file_system_policy = file_system_sandbox_policy + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&original_cwd)); + assert_eq!(updated.workspace_roots, vec![original_cwd.clone()]); assert!( updated .file_system_sandbox_policy() - .is_semantically_equivalent_to(&expected_file_system_policy, &project_root), - "cwd-only update should rederive the legacy filesystem policy for the new cwd" + .is_semantically_equivalent_to(&expected_file_system_policy, original_cwd.as_path()), + "cwd-only update should preserve the existing workspace roots" ); } @@ -3689,6 +3706,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3798,6 +3816,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4028,6 +4047,7 @@ async fn make_session_with_config_and_rx( active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4131,6 +4151,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4778,6 +4799,7 @@ fn op_kind_distinguishes_turn_ops() { final_output_json_schema: None, responsesapi_client_metadata: None, cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, @@ -5173,6 +5195,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), + workspace_roots: config.workspace_roots.clone(), model_provider: config.model_provider_id.clone(), memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled @@ -5518,6 +5541,7 @@ where active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 5a8885e0f3..339e4b318f 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -70,6 +70,7 @@ pub struct TurnContext { /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. pub(crate) cwd: AbsolutePathBuf, + pub(crate) workspace_roots: Vec, pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, @@ -104,7 +105,10 @@ impl TurnContext { } pub(crate) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.file_system_sandbox_policy() + self.permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + .file_system_sandbox_policy() } pub(crate) fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { @@ -253,6 +257,7 @@ impl TurnContext { thread_source: self.thread_source, environments: self.environments.clone(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), @@ -314,6 +319,7 @@ impl TurnContext { FileSystemSandboxContext { permissions, cwd: Some(self.cwd.clone()), + workspace_roots: self.workspace_roots.clone(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config @@ -349,11 +355,13 @@ impl TurnContext { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), cwd: self.cwd.to_path_buf(), + workspace_roots: self.workspace_roots.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy(), permission_profile: Some(self.permission_profile()), + active_permission_profile: self.config.permissions.active_permission_profile(), network: self.turn_context_network_item(), file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), model: self.model_info.slug.clone(), @@ -411,6 +419,7 @@ 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(); per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -419,6 +428,8 @@ impl Session { per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; per_turn_config.permissions.permission_profile = session_configuration.permission_profile.clone(); + per_turn_config.permissions.active_permission_profile = + session_configuration.active_permission_profile(); let permission_profile = session_configuration.permission_profile(); let resolved_web_search_mode = resolve_web_search_mode_for_turn(&per_turn_config.web_search_mode, &permission_profile); @@ -552,6 +563,7 @@ impl Session { thread_source: session_configuration.thread_source, environments, cwd, + workspace_roots: session_configuration.workspace_roots.clone(), current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index dfa642ade1..020e49d2b5 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -210,7 +210,6 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root() let file_path = AbsolutePathBuf::try_from(nested.join("file.txt")) .expect("nested file path should be absolute"); let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -232,7 +231,6 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() { .expect("outside file path should be absolute"); let cwd_abs = cwd.abs(); let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index 7d47290c10..c2636f5531 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -24,84 +24,89 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = parse_agent_id_target(&args.target)?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = parse_agent_id_target(&args.target)?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); - } - }; - let result = Box::pin(session.services.agent_control.close_agent(agent_id)) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; - result?; + }; + let result = Box::pin(session.services.agent_control.close_agent(agent_id)) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; - Ok(CloseAgentResult { - previous_status: status, + Ok(CloseAgentResult { + previous_status: status, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 0b86c9abdf..9ad4af3a48 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -26,111 +26,116 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: ResumeAgentArgs = parse_arguments(&arguments)?; - let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { - FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) - })?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); - let child_depth = next_thread_spawn_depth(&turn.session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: ResumeAgentArgs = parse_arguments(&arguments)?; + let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { + FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) + })?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + let child_depth = next_thread_spawn_depth(&turn.session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } - session - .send_event( - &turn, - CollabResumeBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + session + .send_event( + &turn, + CollabResumeBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + } + .into(), + ) + .await; + + let mut status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { + match Box::pin(try_resume_closed_agent( + &session, + &turn, receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - } - .into(), - ) - .await; - - let mut status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { - match Box::pin(try_resume_closed_agent( - &session, - &turn, - receiver_thread_id, - child_depth, - )) - .await - { - Ok(()) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - ( - session + child_depth, + )) + .await + { + Ok(()) => { + status = session .services .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or(receiver_agent), - None, - ) - } - Err(err) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - (receiver_agent, Some(err)) + .get_status(receiver_thread_id) + .await; + ( + session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or(receiver_agent), + None, + ) + } + Err(err) => { + status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + (receiver_agent, Some(err)) + } } + } else { + (receiver_agent, None) + }; + session + .send_event( + &turn, + CollabResumeEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + + if let Some(err) = error { + return Err(err); } - } else { - (receiver_agent, None) - }; - session - .send_event( - &turn, - CollabResumeEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; + turn.session_telemetry + .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); - if let Some(err) = error { - return Err(err); - } - turn.session_telemetry - .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); - - Ok(ResumeAgentResult { status }) + Ok(ResumeAgentResult { status }) + }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 40e9cc5d38..804f4cda63 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -41,157 +41,162 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = render_input_preview(&input_items); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if args.fork_context { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( - config, - input_items, - Some(thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - /*task_name*/ None, - )?), - SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), - environments: Some(turn.environments.to_selections()), - }, - )) - .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = render_input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); } - None => None, - }; - let (_new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), - ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), - }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, - } - .into(), - ) - .await; - let new_thread_id = result?.thread_id; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if args.fork_context { + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort, + )?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); - Ok(SpawnAgentResult { - agent_id: new_thread_id.to_string(), - nickname, + let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + /*task_name*/ None, + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), + environments: Some(turn.environments.to_selections()), + }, + )) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (_new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let new_thread_id = result?.thread_id; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + + Ok(SpawnAgentResult { + agent_id: new_thread_id.to_string(), + nickname, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index c01755cb2b..75f5120c79 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -269,6 +269,7 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); config.cwd = turn.cwd.clone(); + config.workspace_roots = turn.workspace_roots.clone(); config .permissions .set_permission_profile(turn.permission_profile()) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index b030cb010c..d1360186a0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3179,10 +3179,11 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr let parent_thread_id = parent.thread_id; let parent_session = parent.thread.codex.session.clone(); + let child_turn = parent_session.new_default_turn().await; let child_spawn_output = SpawnAgentHandler::default() .handle(invocation( parent_session.clone(), - parent_session.new_default_turn().await, + child_turn, "spawn_agent", function_payload(json!({"message": "hello child"})), )) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index d3a290d363..a0b06eaffe 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -24,96 +24,101 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - if receiver_agent - .agent_path - .as_ref() - .is_some_and(AgentPath::is_root) - { - return Err(FunctionCallError::RespondToModel( - "root is not a spawned agent".to_string(), - )); - } - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + if receiver_agent + .agent_path + .as_ref() + .is_some_and(AgentPath::is_root) + { + return Err(FunctionCallError::RespondToModel( + "root is not a spawned agent".to_string(), + )); } - }; - let result = session - .services - .agent_control - .close_agent(agent_id) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); } - .into(), - ) - .await; - result?; + }; + let result = session + .services + .agent_control + .close_agent(agent_id) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; - Ok(CloseAgentResult { - previous_status: status, + Ok(CloseAgentResult { + previous_status: status, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index fc44d3df47..34e6f1f195 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -43,188 +43,192 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let fork_mode = args.fork_mode()?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let fork_mode = args.fork_mode()?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); - let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); + let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; + let prompt = render_input_preview(&initial_operation); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let spawn_source = thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - Some(args.task_name.clone()), - )?; - let result = session - .services - .agent_control - .spawn_agent_with_metadata( - config, - match (spawn_source.get_agent_path(), initial_operation) { - (Some(recipient), Op::UserInput { items, .. }) - if items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) => - { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), } - (_, initial_operation) => initial_operation, - }, - Some(spawn_source), - SpawnAgentOptions { - fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), - fork_mode, - environments: Some(turn.environments.to_selections()), - }, + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort, + )?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let spawn_source = thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + Some(args.task_name.clone()), + )?; + let result = Box::pin( + session.services.agent_control.spawn_agent_with_metadata( + config, + match (spawn_source.get_agent_path(), initial_operation) { + (Some(recipient), Op::UserInput { items, .. }) + if items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) => + { + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + recipient, + Vec::new(), + prompt.clone(), + /*trigger_turn*/ true, + ), + } + } + (_, initial_operation) => initial_operation, + }, + Some(spawn_source), + SpawnAgentOptions { + fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), + fork_mode, + environments: Some(turn.environments.to_selections()), + }, + ), ) .await .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await - } - None => None, - }; - let (new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), + Err(_) => (None, None, AgentStatus::NotFound), }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await } - .into(), - ) - .await; - let _ = result?; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); - let task_name = new_agent_path.ok_or_else(|| { - FunctionCallError::RespondToModel( - "spawned agent is missing a canonical task name".to_string(), - ) - })?; + None => None, + }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let _ = result?; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + let task_name = new_agent_path.ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawned agent is missing a canonical task name".to_string(), + ) + })?; - let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; - if hide_agent_metadata { - Ok(SpawnAgentResult::HiddenMetadata { task_name }) - } else { - Ok(SpawnAgentResult::WithNickname { - task_name, - nickname, - }) - } + let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; + if hide_agent_metadata { + Ok(SpawnAgentResult::HiddenMetadata { task_name }) + } else { + Ok(SpawnAgentResult::WithNickname { + task_name, + nickname, + }) + } + }) } } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4fd1ea17f4..f699fb33f2 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -84,6 +84,7 @@ impl ToolOrchestrator { enforce_managed_network: attempt.enforce_managed_network, manager: attempt.manager, sandbox_cwd: attempt.sandbox_cwd, + workspace_roots: attempt.workspace_roots, codex_linux_sandbox_exe: attempt.codex_linux_sandbox_exe, use_legacy_landlock: attempt.use_legacy_landlock, windows_sandbox_level: attempt.windows_sandbox_level, @@ -235,6 +236,7 @@ impl ToolOrchestrator { enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd, + workspace_roots: &turn_ctx.workspace_roots, codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, @@ -353,6 +355,7 @@ impl ToolOrchestrator { enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd, + workspace_roots: &turn_ctx.workspace_roots, codex_linux_sandbox_exe: None, 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 1ab249fb0a..c4b7f45490 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -91,6 +91,7 @@ impl ApplyPatchRuntime { Some(FileSystemSandboxContext { permissions, cwd: Some(attempt.sandbox_cwd.clone()), + 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, 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 173fa3e2a0..580d5eab64 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -149,6 +149,7 @@ fn file_system_sandbox_context_uses_active_attempt() { enforce_managed_network: false, manager: &manager, sandbox_cwd: &path, + workspace_roots: std::slice::from_ref(&path), codex_linux_sandbox_exe: None, use_legacy_landlock: true, windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, @@ -201,6 +202,7 @@ fn no_sandbox_attempt_has_no_file_system_context() { enforce_managed_network: false, manager: &manager, sandbox_cwd: &path, + workspace_roots: std::slice::from_ref(&path), 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 fd10f22242..83669a36cb 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -111,6 +111,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: enforce_managed_network: false, manager: &manager, sandbox_cwd: &cwd, + workspace_roots: std::slice::from_ref(&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/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index fef8db5ca9..935f118194 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -168,6 +168,7 @@ pub(super) async fn try_run_zsh_fork( windows_sandbox_level, arg0, sandbox_policy_cwd, + workspace_roots: attempt.workspace_roots.to_vec(), codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; @@ -232,7 +233,7 @@ pub(super) async fn try_run_zsh_fork( pub(crate) async fn prepare_unified_exec_zsh_fork( req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest, - _attempt: &SandboxAttempt<'_>, + attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, exec_request: ExecRequest, shell_zsh_path: &std::path::Path, @@ -269,6 +270,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), sandbox_policy_cwd: exec_request.windows_sandbox_policy_cwd.clone(), + workspace_roots: attempt.workspace_roots.to_vec(), codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; @@ -753,6 +755,7 @@ struct CoreShellCommandExecutor { windows_sandbox_level: WindowsSandboxLevel, arg0: Option, sandbox_policy_cwd: AbsolutePathBuf, + workspace_roots: Vec, codex_linux_sandbox_exe: Option, use_legacy_landlock: bool, } @@ -923,6 +926,7 @@ impl CoreShellCommandExecutor { enforce_managed_network: self.network.is_some(), network: self.network.as_ref(), sandbox_policy_cwd: &self.sandbox_policy_cwd, + workspace_roots: &self.workspace_roots, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(), use_legacy_landlock: self.use_legacy_landlock, windows_sandbox_level: self.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 6bb78632f7..03289fff9b 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -377,6 +377,7 @@ pub(crate) struct SandboxAttempt<'a> { pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a AbsolutePathBuf, + pub(crate) workspace_roots: &'a [AbsolutePathBuf], pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>, pub use_legacy_landlock: bool, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, @@ -399,6 +400,7 @@ impl<'a> SandboxAttempt<'a> { enforce_managed_network: self.enforce_managed_network, network, sandbox_policy_cwd: self.sandbox_cwd, + workspace_roots: self.workspace_roots, codex_linux_sandbox_exe: self .codex_linux_sandbox_exe .map(std::path::PathBuf::as_path), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 24c25d6364..7cf1485c05 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -519,6 +519,7 @@ impl TestCodexBuilder { load_default_config_for_test(home).await }; config.cwd = cwd_override; + config.workspace_roots = vec![config.cwd.clone()]; config.model_provider = model_provider; if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { config.codex_self_exe = Some(path); @@ -543,6 +544,7 @@ impl TestCodexBuilder { for mutator in mutators { mutator(&mut config); } + config.workspace_roots = vec![config.cwd.clone()]; ensure_test_model_catalog(&mut config)?; if config.include_apply_patch_tool { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 10bbb852eb..c1f27af190 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -826,7 +826,6 @@ fn scenarios() -> Vec { use AskForApproval::*; let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2006,7 +2005,6 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2762,7 +2760,6 @@ allow_local_binding = true )?; let approval_policy = AskForApproval::OnFailure; let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -3042,7 +3039,6 @@ allow_local_binding = true )?; let approval_policy = AskForApproval::OnFailure; let turn_sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 923d223e13..b75ec1a6de 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -53,6 +53,7 @@ where params, &PermissionProfile::read_only(), &cwd, + &[], &None, /*use_legacy_landlock*/ false, /*stdout_stream*/ None, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 4d6259a599..bf5b27de8c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -575,9 +575,13 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let permissions = permissions_texts(&req.single_request()); let normalize_line_endings = |s: &str| s.replace("\r\n", "\n"); let exec_policy = load_exec_policy(&test.config.config_layer_stack).await?; - let sandbox_policy = test.config.legacy_sandbox_policy(); - let expected = PermissionsInstructions::from_policy( - &sandbox_policy, + let permission_profile = test + .config + .permissions + .permission_profile() + .materialize_project_roots_with_workspace_roots(&test.config.workspace_roots); + let expected = PermissionsInstructions::from_permission_profile( + &permission_profile, AskForApproval::OnRequest, test.config.approvals_reviewer, &exec_policy, diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 0b89a9cfba..f38c2b53f3 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -63,6 +63,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R forked_from_id: None, timestamp: TEST_TIMESTAMP.to_string(), cwd: std::path::PathBuf::from("."), + workspace_roots: Vec::new(), originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, @@ -109,6 +110,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re forked_from_id: None, timestamp: TEST_TIMESTAMP.to_string(), cwd: std::path::PathBuf::from("."), + workspace_roots: Vec::new(), originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b81bb06bb9..c9db1501fc 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -436,9 +436,12 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an /*exclude_tmpdir_env_var*/ true, /*exclude_slash_tmp*/ true, ); - let sandbox_policy = permission_profile - .to_legacy_sandbox_policy(config.cwd.as_path()) - .expect("workspace profile should have legacy projection"); + let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &permission_profile, + &permission_profile.file_system_sandbox_policy(), + permission_profile.network_sandbox_policy(), + config.cwd.as_path(), + ); codex .submit(Op::OverrideTurnContext { cwd: None, diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 455c1fabb9..7a8d09f065 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -285,7 +285,6 @@ async fn expect_request_permissions_event( fn workspace_write_excluding_tmp() -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index cb545df351..5af8f8c7fb 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -29,11 +29,13 @@ fn resume_history( turn_id: Some(turn_id.clone()), trace_id: None, cwd: config.cwd.to_path_buf(), + workspace_roots: config.workspace_roots.clone(), current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.legacy_sandbox_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index b2a2778047..55e51b25af 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -141,6 +141,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { forked_from_id: None, timestamp: "2026-01-27T12:00:00Z".to_string(), cwd: codex_home.to_path_buf(), + workspace_roots: Vec::new(), originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 76b0f22b2b..887caab53c 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -109,6 +109,7 @@ impl FileSystemSandboxRunner { enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), + workspace_roots: &sandbox_context.workspace_roots, codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), use_legacy_landlock: sandbox_context.use_legacy_landlock, windows_sandbox_level: sandbox_context.windows_sandbox_level, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index e68c96d00b..136f58bed9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -24,7 +24,6 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; @@ -81,7 +80,6 @@ use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; @@ -422,6 +420,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, ephemeral: ephemeral.then_some(true), + workspace_roots: None, additional_writable_roots: add_dir, }; @@ -775,6 +774,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { responsesapi_client_metadata: None, environments: None, cwd: Some(default_cwd), + workspace_roots: None, approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, sandbox_policy: None, @@ -946,6 +946,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -958,20 +959,15 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { let permissions = permissions_selection_from_config(config); - let sandbox = permissions.is_none().then(|| { - sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), - config.cwd.as_path(), - ) - }); ThreadResumeParams { thread_id, model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: sandbox.flatten(), + sandbox: None, permissions, config: config_request_overrides_from_config(config), ..ThreadResumeParams::default() @@ -988,19 +984,7 @@ fn permissions_selection_from_config(config: &Config) -> Option PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::Profile { id: active.id } } fn sandbox_mode_from_permission_profile( @@ -1079,6 +1063,7 @@ fn session_configured_from_thread_start_response( .unwrap_or_else(|| config.permissions.permission_profile()), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.workspace_roots.clone(), response.reasoning_effort, ) } @@ -1104,6 +1089,7 @@ fn session_configured_from_thread_resume_response( .unwrap_or_else(|| config.permissions.permission_profile()), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.workspace_roots.clone(), response.reasoning_effort, ) } @@ -1134,12 +1120,18 @@ fn session_configured_from_thread_response( permission_profile: PermissionProfile, active_permission_profile: Option, cwd: AbsolutePathBuf, + workspace_roots: Vec, reasoning_effort: Option, ) -> Result { let session_id = SessionId::from_string(session_id) .map_err(|err| format!("session id `{session_id}` is invalid: {err}"))?; let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; + let workspace_roots = if workspace_roots.is_empty() { + vec![cwd.clone()] + } else { + workspace_roots + }; Ok(SessionConfiguredEvent { session_id, @@ -1155,6 +1147,7 @@ fn session_configured_from_thread_response( permission_profile, active_permission_profile, cwd, + workspace_roots, reasoning_effort, initial_messages: None, network_proxy: None, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index c12b483e89..9c7d36473e 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -456,7 +456,7 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() } #[tokio::test] -async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() { +async fn thread_lifecycle_params_handle_legacy_sandbox_when_no_active_profile() { let codex_home = tempdir().expect("create temp codex home"); let cwd = tempdir().expect("create temp cwd"); let config = ConfigBuilder::default() @@ -479,10 +479,7 @@ async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) ); assert_eq!(start_params.permissions, None); - assert_eq!( - resume_params.sandbox, - Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) - ); + assert_eq!(resume_params.sandbox, None); assert_eq!(resume_params.permissions, None); } @@ -531,6 +528,26 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_re assert_eq!(event.permission_profile, PermissionProfile::Disabled); } +#[tokio::test] +async fn session_configured_from_thread_response_uses_workspace_roots_from_response() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + let mut response = sample_thread_start_response(); + let extra_root = test_path_buf("/tmp/extra-root").abs(); + response.workspace_roots = vec![response.cwd.clone(), extra_root.clone()]; + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!(event.workspace_roots, vec![response.cwd, extra_root]); +} + fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { @@ -558,11 +575,11 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: codex_app_server_protocol::AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index efda58f412..7b7cb87c8c 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -121,6 +121,7 @@ fn session_configured_produces_thread_started_event() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + workspace_roots: Vec::new(), reasoning_effort: None, initial_messages: None, network_proxy: None, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index feb1a7b8c8..8e9d6b18d8 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -16,6 +16,7 @@ async fn spawn_command_under_sandbox( command_cwd: AbsolutePathBuf, sandbox_policy: &SandboxPolicy, sandbox_cwd: &AbsolutePathBuf, + additional_workspace_roots: &[AbsolutePathBuf], stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { @@ -28,6 +29,8 @@ async fn spawn_command_under_sandbox( use std::process::Stdio; let codex_linux_sandbox_exe = None; + let mut workspace_roots = vec![sandbox_cwd.clone()]; + workspace_roots.extend_from_slice(additional_workspace_roots); let exec_request = build_exec_request( ExecParams { command, @@ -44,6 +47,7 @@ async fn spawn_command_under_sandbox( }, &PermissionProfile::from_legacy_sandbox_policy(sandbox_policy), sandbox_cwd, + &workspace_roots, &codex_linux_sandbox_exe, /*use_legacy_landlock*/ false, ) @@ -85,6 +89,7 @@ async fn spawn_command_under_sandbox( command_cwd: AbsolutePathBuf, sandbox_policy: &SandboxPolicy, sandbox_cwd: &AbsolutePathBuf, + additional_workspace_roots: &[AbsolutePathBuf], stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { @@ -93,7 +98,10 @@ async fn spawn_command_under_sandbox( let codex_linux_sandbox_exe = core_test_support::find_codex_linux_sandbox_exe() .map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?; - let permission_profile = PermissionProfile::from_legacy_sandbox_policy(sandbox_policy); + let mut workspace_roots = vec![sandbox_cwd.clone()]; + workspace_roots.extend_from_slice(additional_workspace_roots); + let permission_profile = PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) + .materialize_project_roots_with_workspace_roots(&workspace_roots); spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, @@ -145,6 +153,7 @@ async fn can_apply_linux_sandbox_policy( command_cwd.clone(), policy, sandbox_cwd, + &[], StdioPolicy::RedirectForShellTool, env, ) @@ -181,7 +190,6 @@ async fn python_multiprocessing_lock_works_under_sandbox() { let writable_roots: Vec = vec!["/dev/shm".try_into().unwrap()]; let policy = SandboxPolicy::WorkspaceWrite { - writable_roots, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -212,6 +220,7 @@ if __name__ == '__main__': command_cwd, &policy, &sandbox_cwd, + &writable_roots, StdioPolicy::Inherit, sandbox_env, ) @@ -255,6 +264,7 @@ async fn python_getpwuid_works_under_sandbox() { command_cwd, &policy, &sandbox_cwd, + &[], StdioPolicy::RedirectForShellTool, sandbox_env, ) @@ -295,7 +305,6 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { // writable only because it is under the sandbox policy cwd, not because it // is under a writable root. let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -311,6 +320,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { command_root.clone(), &policy, &canonical_sandbox_root, + &[], StdioPolicy::Inherit, sandbox_env.clone(), ) @@ -342,6 +352,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { command_root, &policy, &canonical_sandbox_root, + &[], StdioPolicy::Inherit, sandbox_env, ) @@ -376,7 +387,6 @@ async fn sandbox_blocks_first_time_dot_codex_creation() { let dot_codex = repo_root.join(".codex"); let config_toml = dot_codex.join("config.toml"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -392,6 +402,7 @@ async fn sandbox_blocks_first_time_dot_codex_creation() { repo_root.clone(), &policy, &repo_root, + &[], StdioPolicy::RedirectForShellTool, sandbox_env, ) @@ -546,6 +557,7 @@ where command_cwd, policy, &sandbox_cwd, + &[], stdio_policy, HashMap::from([("IN_SANDBOX".into(), "1".into())]), ) diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 026523eb2f..97e3a8bf7b 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -50,6 +50,8 @@ pub struct FileSystemSandboxContext { pub permissions: PermissionProfile, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub windows_sandbox_level: WindowsSandboxLevel, #[serde(default)] pub windows_sandbox_private_desktop: bool, @@ -77,16 +79,34 @@ impl FileSystemSandboxContext { permissions: PermissionProfile, cwd: AbsolutePathBuf, ) -> Self { - Self::from_permissions_and_cwd(permissions, Some(cwd)) + Self::from_permission_profile_with_workspace_roots(permissions, cwd.clone(), vec![cwd]) } fn from_permissions_and_cwd( permissions: PermissionProfile, cwd: Option, + ) -> Self { + let workspace_roots = cwd.iter().cloned().collect(); + Self::from_permissions_cwd_and_workspace_roots(permissions, cwd, workspace_roots) + } + + pub fn from_permission_profile_with_workspace_roots( + permissions: PermissionProfile, + cwd: AbsolutePathBuf, + workspace_roots: Vec, + ) -> Self { + Self::from_permissions_cwd_and_workspace_roots(permissions, Some(cwd), workspace_roots) + } + + fn from_permissions_cwd_and_workspace_roots( + permissions: PermissionProfile, + cwd: Option, + workspace_roots: Vec, ) -> Self { Self { permissions, cwd, + workspace_roots, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 87e4ce68ae..e0bee8c253 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -190,6 +190,7 @@ async fn run_cmd_result_with_permission_profile_for_cwd( params, &permission_profile, &sandbox_cwd, + std::slice::from_ref(&sandbox_cwd), &codex_linux_sandbox_exe, use_legacy_landlock, /*stdout_stream*/ None, @@ -448,6 +449,7 @@ async fn assert_network_blocked(cmd: &[&str]) { params, &permission_profile, &sandbox_cwd, + std::slice::from_ref(&sandbox_cwd), &codex_linux_sandbox_exe, /*use_legacy_landlock*/ false, /*stdout_stream*/ None, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index b2882643ce..7d507f24b2 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -309,6 +309,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + workspace_roots: Vec::new(), reasoning_effort: Some(ReasoningEffort::default()), initial_messages: None, network_proxy: None, @@ -354,6 +355,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + workspace_roots: Vec::new(), reasoning_effort: Some(ReasoningEffort::default()), initial_messages: None, network_proxy: None, @@ -421,6 +423,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + workspace_roots: Vec::new(), reasoning_effort: Some(ReasoningEffort::default()), initial_messages: None, network_proxy: None, diff --git a/codex-rs/memories/write/src/phase2.rs b/codex-rs/memories/write/src/phase2.rs index 7a3841f8c3..7ba92feb23 100644 --- a/codex-rs/memories/write/src/phase2.rs +++ b/codex-rs/memories/write/src/phase2.rs @@ -17,9 +17,15 @@ use codex_config::Constrained; use codex_core::config::Config; use codex_features::Feature; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; +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::AgentStatus; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_state::Stage1Output; @@ -315,18 +321,25 @@ mod agent { .features .disable(Feature::SkillMcpDependencyInstall); - // Sandbox policy - let writable_roots = vec![root]; // The consolidation agent only needs local memory-root write access and no network. - let consolidation_sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: root }, + access: FileSystemAccessMode::Write, + }, + ]); agent_config .permissions - .set_legacy_sandbox_policy(consolidation_sandbox_policy, agent_config.cwd.as_path()) + .set_permission_profile(PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + )) .ok()?; agent_config.model = Some( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 5a0fafad94..92ad4db6f3 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -335,21 +335,6 @@ pub struct ActivePermissionProfile { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub extends: Option, - - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub modifications: Vec, -} - -#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "snake_case")] - #[ts(rename_all = "snake_case")] - AdditionalWritableRoot { path: AbsolutePathBuf }, } impl ActivePermissionProfile { @@ -357,17 +342,8 @@ impl ActivePermissionProfile { Self { id: id.into(), extends: None, - modifications: Vec::new(), } } - - pub fn with_modifications( - mut self, - modifications: Vec, - ) -> Self { - self.modifications = modifications; - self - } } impl Default for PermissionProfile { @@ -539,6 +515,20 @@ impl PermissionProfile { self.network_sandbox_policy(), ) } + + pub fn materialize_project_roots_with_workspace_roots( + &self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let (file_system_sandbox_policy, network_sandbox_policy) = self.to_runtime_permissions(); + let file_system_sandbox_policy = file_system_sandbox_policy + .materialize_project_roots_with_workspace_roots(workspace_roots); + Self::from_runtime_permissions_with_enforcement( + self.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ) + } } #[derive(Debug, Clone, Deserialize)] diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index c1f3d0724b..93da639d60 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -538,27 +538,16 @@ impl FileSystemSandboxPolicy { /// into split filesystem policy. pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self { let mut file_system_policy = Self::from(sandbox_policy); - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy { - if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) { - for protected_path in default_read_only_subpaths_for_writable_root( - &cwd_root, /*protect_missing_dot_codex*/ true, - ) { - append_default_read_only_path_if_no_explicit_rule( - &mut file_system_policy.entries, - protected_path, - ); - } - } - for writable_root in writable_roots { - for protected_path in default_read_only_subpaths_for_writable_root( - writable_root, - /*protect_missing_dot_codex*/ false, - ) { - append_default_read_only_path_if_no_explicit_rule( - &mut file_system_policy.entries, - protected_path, - ); - } + if let SandboxPolicy::WorkspaceWrite { .. } = sandbox_policy + && let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) + { + for protected_path in default_read_only_subpaths_for_writable_root( + &cwd_root, /*protect_missing_dot_codex*/ true, + ) { + append_default_read_only_path_if_no_explicit_rule( + &mut file_system_policy.entries, + protected_path, + ); } } @@ -679,6 +668,38 @@ impl FileSystemSandboxPolicy { self } + /// Replaces symbolic `:project_roots` entries with concrete entries for + /// each workspace root. + pub fn materialize_project_roots_with_workspace_roots( + mut self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let mut entries = Vec::with_capacity(self.entries.len()); + for entry in self.entries { + let FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath }, + } = &entry.path + else { + entries.push(entry); + continue; + }; + + entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: match subpath.as_ref() { + Some(subpath) => { + AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path()) + } + None => root.clone(), + }, + }, + access: entry.access, + })); + } + self.entries = entries; + self + } + pub fn with_additional_readable_roots( mut self, cwd: &Path, @@ -994,10 +1015,10 @@ impl FileSystemSandboxPolicy { let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); let has_full_disk_write_access = self.has_full_disk_write_access(); let mut workspace_root_writable = false; - let mut writable_roots = Vec::new(); let mut tmpdir_writable = false; let mut slash_tmp_writable = false; let mut unbridgeable_root_write = false; + let mut unbridgeable_external_write = false; for entry in &self.entries { match &entry.path { @@ -1007,7 +1028,7 @@ impl FileSystemSandboxPolicy { if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) { workspace_root_writable = true; } else { - writable_roots.push(path.clone()); + unbridgeable_external_write = true; } } } @@ -1023,11 +1044,8 @@ impl FileSystemSandboxPolicy { FileSystemSpecialPath::ProjectRoots { subpath } => { if subpath.is_none() && entry.access.can_write() { workspace_root_writable = true; - } else if let Some(path) = - resolve_file_system_special_path(value, cwd_absolute.as_ref()) - && entry.access.can_write() - { - writable_roots.push(path); + } else if entry.access.can_write() { + unbridgeable_external_write = true; } } FileSystemSpecialPath::Tmpdir => { @@ -1055,21 +1073,20 @@ impl FileSystemSandboxPolicy { }); } + if unbridgeable_root_write || unbridgeable_external_write { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly", + )); + } + if workspace_root_writable { SandboxPolicy::WorkspaceWrite { - writable_roots: dedup_absolute_paths( - writable_roots, - /*normalize_effective_paths*/ false, - ), network_access: network_policy.is_enabled(), exclude_tmpdir_env_var: !tmpdir_writable, exclude_slash_tmp: !slash_tmp_writable, } - } else if unbridgeable_root_write - || !writable_roots.is_empty() - || tmpdir_writable - || slash_tmp_writable - { + } else if tmpdir_writable || slash_tmp_writable { return Err(io::Error::new( io::ErrorKind::InvalidInput, "permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly", @@ -1135,12 +1152,11 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy { }]) } SandboxPolicy::WorkspaceWrite { - writable_roots, exclude_tmpdir_env_var, exclude_slash_tmp, .. } => FileSystemSandboxPolicy::workspace_write( - writable_roots, + &[], *exclude_tmpdir_env_var, *exclude_slash_tmp, ), @@ -1447,7 +1463,6 @@ fn legacy_runtime_file_system_policy_for_cwd( cwd: &Path, ) -> FileSystemSandboxPolicy { let SandboxPolicy::WorkspaceWrite { - writable_roots, exclude_tmpdir_env_var, exclude_slash_tmp, .. @@ -1487,16 +1502,6 @@ fn legacy_runtime_file_system_policy_for_cwd( access: FileSystemAccessMode::Write, }); } - entries.extend( - writable_roots - .iter() - .cloned() - .map(|path| FileSystemSandboxEntry { - path: FileSystemPath::Path { path }, - access: FileSystemAccessMode::Write, - }), - ); - if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) { for protected_path in default_read_only_subpaths_for_writable_root( &cwd_root, /*protect_missing_dot_codex*/ true, @@ -1504,14 +1509,6 @@ fn legacy_runtime_file_system_policy_for_cwd( append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path); } } - for writable_root in writable_roots { - for protected_path in default_read_only_subpaths_for_writable_root( - writable_root, - /*protect_missing_dot_codex*/ false, - ) { - append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path); - } - } FileSystemSandboxPolicy::restricted(entries) } @@ -1770,6 +1767,29 @@ mod tests { Ok(()) } + #[test] + fn legacy_bridge_rejects_extra_writable_root_when_cwd_is_writable() { + let cwd = TempDir::new().expect("tempdir"); + let cwd_path = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd"); + let extra_root = AbsolutePathBuf::resolve_path_against_base("extra-root", cwd.path()); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: cwd_path }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: extra_root }, + access: FileSystemAccessMode::Write, + }, + ]); + + let err = policy + .to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd.path()) + .expect_err("extra writable roots cannot be represented by WorkspaceWrite"); + + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + #[cfg(unix)] #[test] fn writable_roots_proactively_protect_missing_dot_codex() { @@ -1800,7 +1820,6 @@ mod tests { #[test] fn legacy_workspace_write_projection_preserves_symbolic_project_root() { let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1956,7 +1975,6 @@ mod tests { ) .expect("absolute root"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2472,7 +2490,6 @@ mod tests { fn legacy_projection_runtime_enforcement_ignores_entry_order() { let cwd = TempDir::new().expect("tempdir"); let legacy_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2499,7 +2516,6 @@ mod tests { fn missing_symbolic_metadata_carveouts_need_direct_runtime_enforcement() { let cwd = TempDir::new().expect("tempdir"); let legacy_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91fd02d858..a557184c85 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -462,6 +462,10 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] cwd: Option, + /// Updated workspace roots for sandbox/tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + workspace_roots: Option>, + /// Updated command approval policy. #[serde(skip_serializing_if = "Option::is_none")] approval_policy: Option, @@ -1004,14 +1008,9 @@ pub enum SandboxPolicy { }, /// Same as `ReadOnly` but additionally grants write access to the current - /// working directory ("workspace"). + /// thread workspace roots. #[serde(rename = "workspace-write")] WorkspaceWrite { - /// Additional folders (beyond cwd and possibly TMPDIR) that should be - /// writable from within the sandbox. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - writable_roots: Vec, - /// When set to `true`, outbound network access is allowed. `false` by /// default. #[serde(default)] @@ -1121,7 +1120,6 @@ impl SandboxPolicy { /// not allow network access. pub fn new_workspace_write_policy() -> Self { SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -1159,13 +1157,11 @@ impl SandboxPolicy { SandboxPolicy::ExternalSandbox { .. } => Vec::new(), SandboxPolicy::ReadOnly { .. } => Vec::new(), SandboxPolicy::WorkspaceWrite { - writable_roots, exclude_tmpdir_env_var, exclude_slash_tmp, network_access: _, } => { - // Start from explicitly configured writable roots. - let mut roots: Vec = writable_roots.clone(); + let mut roots: Vec = Vec::new(); // Always include defaults: cwd, /tmp (if present on Unix), and // on macOS, the per-user TMPDIR unless explicitly excluded. @@ -2704,6 +2700,8 @@ pub struct SessionMeta { pub forked_from_id: Option, pub timestamp: String, pub cwd: PathBuf, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub originator: String, pub cli_version: String, #[serde(default)] @@ -2738,6 +2736,7 @@ impl Default for SessionMeta { forked_from_id: None, timestamp: String::new(), cwd: PathBuf::new(), + workspace_roots: Vec::new(), originator: String::new(), cli_version: String::new(), source: SessionSource::default(), @@ -2808,6 +2807,8 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub trace_id: Option, pub cwd: PathBuf, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_date: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2816,6 +2817,8 @@ pub struct TurnContextItem { pub sandbox_policy: SandboxPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_permission_profile: Option, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3448,6 +3451,10 @@ pub struct SessionConfiguredEvent { /// session. pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` entries. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, + /// The effort the model is putting into reasoning about the user's request. #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, @@ -3496,6 +3503,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { #[serde(default)] active_permission_profile: Option, cwd: AbsolutePathBuf, + #[serde(default)] + workspace_roots: Vec, reasoning_effort: Option, initial_messages: Option>, network_proxy: Option, @@ -3513,6 +3522,11 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { return Err(serde::de::Error::missing_field("permission_profile")); } }; + let workspace_roots = if wire.workspace_roots.is_empty() { + vec![wire.cwd.clone()] + } else { + wire.workspace_roots + }; Ok(Self { session_id: wire.session_id, @@ -3528,6 +3542,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { permission_profile, active_permission_profile: wire.active_permission_profile, cwd: wire.cwd, + workspace_roots, reasoning_effort: wire.reasoning_effort, initial_messages: wire.initial_messages, network_proxy: wire.network_proxy, @@ -4477,7 +4492,6 @@ mod tests { #[test] fn legacy_sandbox_policy_semantics_survive_split_bridge() { let cwd = TempDir::new().expect("tempdir"); - let writable_root = AbsolutePathBuf::resolve_path_against_base("writable", cwd.path()); let policies = [ SandboxPolicy::DangerFullAccess, SandboxPolicy::ExternalSandbox { @@ -4490,13 +4504,11 @@ mod tests { network_access: false, }, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: true, @@ -5180,11 +5192,13 @@ mod tests { turn_id: None, trace_id: None, cwd: test_path_buf("/tmp"), + workspace_roots: Vec::new(), current_date: None, timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, permission_profile: None, + active_permission_profile: None, network: Some(TurnContextNetworkItem { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], @@ -5257,6 +5271,7 @@ mod tests { permission_profile: permission_profile.clone(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + workspace_roots: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), initial_messages: None, network_proxy: None, diff --git a/codex-rs/rollout/Cargo.toml b/codex-rs/rollout/Cargo.toml index ef5a8dc22a..166cc804b0 100644 --- a/codex-rs/rollout/Cargo.toml +++ b/codex-rs/rollout/Cargo.toml @@ -22,6 +22,7 @@ codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-state = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } codex-utils-string = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/rollout/src/config.rs b/codex-rs/rollout/src/config.rs index 420949bfbd..873553ae91 100644 --- a/codex-rs/rollout/src/config.rs +++ b/codex-rs/rollout/src/config.rs @@ -2,10 +2,13 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use codex_utils_absolute_path::AbsolutePathBuf; + pub trait RolloutConfigView { fn codex_home(&self) -> &Path; fn sqlite_home(&self) -> &Path; fn cwd(&self) -> &Path; + fn workspace_roots(&self) -> &[AbsolutePathBuf]; fn model_provider_id(&self) -> &str; fn generate_memories(&self) -> bool; } @@ -15,6 +18,7 @@ pub struct RolloutConfig { pub codex_home: PathBuf, pub sqlite_home: PathBuf, pub cwd: PathBuf, + pub workspace_roots: Vec, pub model_provider_id: String, pub generate_memories: bool, } @@ -27,6 +31,7 @@ impl RolloutConfig { codex_home: view.codex_home().to_path_buf(), sqlite_home: view.sqlite_home().to_path_buf(), cwd: view.cwd().to_path_buf(), + workspace_roots: view.workspace_roots().to_vec(), model_provider_id: view.model_provider_id().to_string(), generate_memories: view.generate_memories(), } @@ -46,6 +51,10 @@ impl RolloutConfigView for RolloutConfig { self.cwd.as_path() } + fn workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + fn model_provider_id(&self) -> &str { self.model_provider_id.as_str() } @@ -68,6 +77,10 @@ impl RolloutConfigView for &T { (*self).cwd() } + fn workspace_roots(&self) -> &[AbsolutePathBuf] { + (*self).workspace_roots() + } + fn model_provider_id(&self) -> &str { (*self).model_provider_id() } @@ -90,6 +103,10 @@ impl RolloutConfigView for Arc { self.as_ref().cwd() } + fn workspace_roots(&self) -> &[AbsolutePathBuf] { + self.as_ref().workspace_roots() + } + fn model_provider_id(&self) -> &str { self.as_ref().model_provider_id() } diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 45db758c65..c253f4db81 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -37,6 +37,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { forked_from_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), + workspace_roots: Vec::new(), originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), @@ -89,6 +90,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { forked_from_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), + workspace_roots: Vec::new(), originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), @@ -349,6 +351,7 @@ fn write_rollout_in_sessions_with_cwd( forked_from_id: None, timestamp: event_ts.to_string(), cwd, + workspace_roots: Vec::new(), originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 369c9f20a8..727b631a4c 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -697,6 +697,7 @@ impl RolloutRecorder { forked_from_id, timestamp, cwd: config.cwd().to_path_buf(), + workspace_roots: config.workspace_roots().to_vec(), originator: originator().value, cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: source.get_nickname(), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 2063020be2..e9a7cdc8a2 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -32,6 +32,7 @@ fn test_config(codex_home: &Path) -> RolloutConfig { codex_home: codex_home.to_path_buf(), sqlite_home: codex_home.to_path_buf(), cwd: codex_home.to_path_buf(), + workspace_roots: Vec::new(), model_provider_id: "test-provider".to_string(), generate_memories: true, } @@ -88,6 +89,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { forked_from_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: home.path().to_path_buf(), + workspace_roots: Vec::new(), originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::Cli, @@ -1348,11 +1350,13 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re turn_id: Some("turn-1".to_string()), trace_id: None, cwd: latest_cwd.clone(), + workspace_roots: Vec::new(), current_date: None, timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: "test-model".to_string(), diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 757b08b4d4..c9a5d501b0 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -29,6 +29,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res forked_from_id: None, timestamp, cwd: ".".into(), + workspace_roots: Vec::new(), originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::Cli, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 22f9a73a4e..76cc148720 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1380,6 +1380,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { forked_from_id: None, timestamp: ts.to_string(), cwd: ".".into(), + workspace_roots: Vec::new(), originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 82c49f7908..8b13daab65 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -97,6 +97,7 @@ pub struct SandboxTransformRequest<'a> { // to make shared ownership explicit across runtime/sandbox plumbing. pub network: Option<&'a NetworkProxy>, pub sandbox_policy_cwd: &'a Path, + pub workspace_roots: &'a [AbsolutePathBuf], pub codex_linux_sandbox_exe: Option<&'a Path>, pub use_legacy_landlock: bool, pub windows_sandbox_level: WindowsSandboxLevel, @@ -176,6 +177,7 @@ impl SandboxManager { enforce_managed_network, network, sandbox_policy_cwd, + workspace_roots, codex_linux_sandbox_exe, use_legacy_landlock, windows_sandbox_level, @@ -183,7 +185,8 @@ impl SandboxManager { } = request; let additional_permissions = command.additional_permissions.take(); let effective_permission_profile = - effective_permission_profile(permissions, additional_permissions.as_ref()); + effective_permission_profile(permissions, additional_permissions.as_ref()) + .materialize_project_roots_with_workspace_roots(workspace_roots); let (effective_file_system_policy, effective_network_policy) = effective_permission_profile.to_runtime_permissions(); let mut argv = Vec::with_capacity(1 + command.args.len()); @@ -278,13 +281,6 @@ fn compatibility_workspace_write_policy( network_policy: NetworkSandboxPolicy, cwd: &Path, ) -> SandboxPolicy { - let cwd_abs = AbsolutePathBuf::from_absolute_path(cwd).ok(); - let writable_roots = file_system_policy - .get_writable_roots_with_cwd(cwd) - .into_iter() - .map(|root| root.root) - .filter(|root| cwd_abs.as_ref() != Some(root)) - .collect(); let tmpdir_writable = std::env::var_os("TMPDIR") .filter(|tmpdir| !tmpdir.is_empty()) .and_then(|tmpdir| { @@ -297,7 +293,6 @@ fn compatibility_workspace_write_policy( && file_system_policy.can_write_path_with_cwd(slash_tmp, cwd); SandboxPolicy::WorkspaceWrite { - writable_roots, network_access: network_policy.is_enabled(), exclude_tmpdir_env_var: !tmpdir_writable, exclude_slash_tmp: !slash_tmp_writable, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 31f74b9c0a..43ea6655ae 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -91,6 +91,7 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), + workspace_roots: std::slice::from_ref(&cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, @@ -142,6 +143,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), + workspace_roots: std::slice::from_ref(&cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, @@ -210,6 +212,7 @@ fn transform_additional_permissions_preserves_denied_entries() { enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), + workspace_roots: std::slice::from_ref(&cwd), codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, @@ -263,6 +266,7 @@ fn transform_linux_seccomp_request( enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), + workspace_roots: std::slice::from_ref(&cwd), codex_linux_sandbox_exe: Some(codex_linux_sandbox_exe), use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index ecaea5d9af..8dc5448316 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -164,7 +164,6 @@ fn create_seatbelt_args_routes_network_through_proxy_ports() { fn dynamic_network_policy_allows_tls_without_darwin_user_cache_write() { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -437,7 +436,6 @@ fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_ports() { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -473,7 +471,6 @@ fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_ fn dynamic_network_policy_blocks_dns_when_local_binding_has_no_proxy_ports() { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -501,7 +498,6 @@ fn dynamic_network_policy_blocks_dns_when_local_binding_has_no_proxy_ports() { fn dynamic_network_policy_preserves_restricted_policy_for_managed_network_without_proxy_config() { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -791,7 +787,6 @@ fn create_seatbelt_args_allows_all_unix_sockets_when_enabled() { fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -838,15 +833,15 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { // Build a policy that only includes the two test roots as writable and // does not automatically include defaults TMPDIR or /tmp. - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![vulnerable_root, empty_root] - .into_iter() - .map(|p| p.try_into().unwrap()) - .collect(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let writable_roots = vec![vulnerable_root, empty_root] + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).expect("absolute writable root")) + .collect::>(); + let file_system_sandbox_policy = FileSystemSandboxPolicy::workspace_write( + &writable_roots, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); // Create the Seatbelt command to wrap a shell command that tries to // write to .codex/config.toml in the vulnerable root. @@ -863,13 +858,15 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args_for_legacy_policy( - shell_command.clone(), - &policy, - &cwd, - /*enforce_managed_network*/ false, - /*network*/ None, - ); + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: shell_command.clone(), + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: &cwd, + enforce_managed_network: false, + network: None, + extra_allow_unix_sockets: &[], + }); let policy_text = seatbelt_policy_arg(&args); assert!( @@ -1002,13 +999,15 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_hooks_file_args = create_seatbelt_command_args_for_legacy_policy( - shell_command_git, - &policy, - &cwd, - /*enforce_managed_network*/ false, - /*network*/ None, - ); + let write_hooks_file_args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: shell_command_git, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: &cwd, + enforce_managed_network: false, + network: None, + extra_allow_unix_sockets: &[], + }); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -1038,13 +1037,15 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_allowed_file_args = create_seatbelt_command_args_for_legacy_policy( - shell_command_allowed, - &policy, - &cwd, - /*enforce_managed_network*/ false, - /*network*/ None, - ); + let write_allowed_file_args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: shell_command_allowed, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: &cwd, + enforce_managed_network: false, + network: None, + extra_allow_unix_sockets: &[], + }); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -1085,7 +1086,6 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_metadata_name_r let dot_codex = repo_root.join(".codex"); let config_toml = dot_codex.join("config.toml"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![repo_root.as_path().try_into().expect("absolute repo root")], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1137,12 +1137,13 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { let cwd = tmp.path().join("cwd"); fs::create_dir_all(&cwd).expect("create cwd"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let worktree_root = + AbsolutePathBuf::from_absolute_path(worktree_root).expect("worktree_root is absolute"); + let file_system_sandbox_policy = FileSystemSandboxPolicy::workspace_write( + std::slice::from_ref(&worktree_root), + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let shell_command: Vec = [ "bash", @@ -1154,13 +1155,15 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args_for_legacy_policy( - shell_command, - &policy, - &cwd, - /*enforce_managed_network*/ false, - /*network*/ None, - ); + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: shell_command, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: &cwd, + enforce_managed_network: false, + network: None, + extra_allow_unix_sockets: &[], + }); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -1190,13 +1193,15 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let gitdir_args = create_seatbelt_command_args_for_legacy_policy( - shell_command_gitdir, - &policy, - &cwd, - /*enforce_managed_network*/ false, - /*network*/ None, - ); + let gitdir_args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: shell_command_gitdir, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: &cwd, + enforce_managed_network: false, + network: None, + extra_allow_unix_sockets: &[], + }); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -1234,7 +1239,6 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { // use the default ones (cwd and TMPDIR) and verifies the protected // metadata checks are done properly for cwd. let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index d815d444ce..28f1b76c25 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -315,6 +315,7 @@ mod tests { ), timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/child/worktree"), + workspace_roots: Vec::new(), originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, @@ -337,11 +338,13 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/parent/workspace"), + workspace_roots: Vec::new(), current_date: None, timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -377,11 +380,13 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/fallback/workspace"), + workspace_roots: Vec::new(), current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -411,11 +416,13 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/fallback/workspace"), + workspace_roots: Vec::new(), current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, + active_permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -449,6 +456,7 @@ mod tests { forked_from_id: None, timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/workspace"), + workspace_roots: Vec::new(), originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 925fb45288..6b41793b73 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1392,6 +1392,7 @@ mod tests { forked_from_id: None, timestamp: metadata.created_at.to_rfc3339(), cwd: PathBuf::new(), + workspace_roots: Vec::new(), originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, @@ -1451,6 +1452,7 @@ mod tests { forked_from_id: None, timestamp: created_at, cwd: PathBuf::new(), + workspace_roots: Vec::new(), originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 191c294c9e..527ba46900 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -210,6 +210,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_session_picker_view: SessionPickerViewMode::Dense, tui_vim_mode_default: false, cwd, + workspace_roots: Vec::new(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::File, diff --git a/codex-rs/thread-store/Cargo.toml b/codex-rs/thread-store/Cargo.toml index 0f8e83fe60..765db63098 100644 --- a/codex-rs/thread-store/Cargo.toml +++ b/codex-rs/thread-store/Cargo.toml @@ -19,6 +19,7 @@ codex-git-utils = { workspace = true } codex-protocol = { workspace = true } codex-rollout = { workspace = true } codex-state = { workspace = true } +codex-utils-absolute-path = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index d181149406..0f9f761c5c 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -24,6 +24,7 @@ pub(super) async fn create_thread( codex_home: store.config.codex_home.clone(), sqlite_home: store.config.sqlite_home.clone(), cwd, + workspace_roots: params.metadata.workspace_roots, model_provider_id: params.metadata.model_provider.clone(), generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled), }; diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index ede9e9e9c8..62d2e150ea 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -44,6 +44,7 @@ pub(super) async fn list_threads( codex_home: store.config.codex_home.clone(), sqlite_home: store.config.sqlite_home.clone(), cwd: store.config.codex_home.clone(), + workspace_roots: Vec::new(), model_provider_id: store.config.default_model_provider_id.clone(), generate_memories: false, }; diff --git a/codex-rs/thread-store/src/local/live_writer.rs b/codex-rs/thread-store/src/local/live_writer.rs index 643207b59d..2d7ae64397 100644 --- a/codex-rs/thread-store/src/local/live_writer.rs +++ b/codex-rs/thread-store/src/local/live_writer.rs @@ -68,6 +68,7 @@ pub(super) async fn resume_thread( codex_home: store.config.codex_home.clone(), sqlite_home: store.config.sqlite_home.clone(), cwd, + workspace_roots: params.metadata.workspace_roots, model_provider_id: params.metadata.model_provider.clone(), generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled), }; diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 07aa5e925f..355c32f8d5 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -519,6 +519,7 @@ mod tests { include_archived: true, metadata: ThreadPersistenceMetadata { cwd: None, + workspace_roots: Vec::new(), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, }, @@ -749,6 +750,7 @@ mod tests { fn thread_metadata() -> ThreadPersistenceMetadata { ThreadPersistenceMetadata { cwd: Some(std::env::current_dir().expect("cwd")), + workspace_roots: Vec::new(), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index ef69cfa8b8..853cf51272 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -1019,6 +1019,7 @@ mod tests { fn test_thread_metadata() -> ThreadPersistenceMetadata { ThreadPersistenceMetadata { cwd: Some(std::env::current_dir().expect("cwd")), + workspace_roots: Vec::new(), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, } diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 1fed7bc829..2ad9280215 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -14,6 +14,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -34,6 +35,9 @@ pub struct ThreadPersistenceMetadata { /// /// `None` means the thread has no filesystem/environment context. pub cwd: Option, + /// Thread-scoped workspace roots used to materialize permission profiles. + #[serde(default)] + pub workspace_roots: Vec, /// Model provider associated with the thread. pub model_provider: String, /// Memory mode associated with the live thread. diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f25398b0b8..ee57a27837 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -601,7 +601,6 @@ impl App { cwd.clone(), *approval_policy, approvals_reviewer, - permission_profile.clone(), active_permission_profile, model.to_string(), *effort, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 73c4c807ef..d6ea6b9332 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -5,7 +5,6 @@ use crate::bottom_pane::FeedbackAudience; use crate::legacy_core::config::Config; -use crate::permission_compat::legacy_compatible_permission_profile; use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; @@ -34,7 +33,6 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; @@ -105,7 +103,6 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -525,7 +522,6 @@ impl AppServerSession { cwd: PathBuf, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - permission_profile: PermissionProfile, active_permission_profile: Option, model: String, effort: Option, @@ -536,12 +532,8 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); - let (sandbox_policy, permissions) = turn_permissions_overrides( - &permission_profile, - active_permission_profile, - cwd.as_path(), - self.thread_params_mode(), - ); + let permissions = + turn_permissions_selection(active_permission_profile, self.thread_params_mode()); self.client .request_typed(ClientRequest::TurnStart { request_id, @@ -551,9 +543,10 @@ impl AppServerSession { responsesapi_client_metadata: None, environments: None, cwd: Some(cwd), + workspace_roots: None, approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), - sandbox_policy, + sandbox_policy: None, permissions, model: Some(model), service_tier, @@ -1150,47 +1143,18 @@ fn sandbox_mode_from_permission_profile( fn permissions_selection_from_active_profile( active: ActivePermissionProfile, ) -> PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::Profile { id: active.id } } -fn turn_permissions_overrides( - permission_profile: &PermissionProfile, +fn turn_permissions_selection( active_permission_profile: Option, - cwd: &std::path::Path, thread_params_mode: ThreadParamsMode, -) -> ( - Option, - Option, -) { - let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { - active_permission_profile.map(permissions_selection_from_active_profile) - } else { - None - }; - let sandbox_policy = (matches!(thread_params_mode, ThreadParamsMode::Remote) - || permissions.is_none()) - .then(|| { - let legacy_profile = legacy_compatible_permission_profile(permission_profile, cwd); - let policy = legacy_profile - .to_legacy_sandbox_policy(cwd) - .unwrap_or_else(|err| { - unreachable!("legacy-compatible permissions must project to legacy policy: {err}") - }); - policy.into() - }); - (sandbox_policy, permissions) +) -> Option { + if matches!(thread_params_mode, ThreadParamsMode::Remote) { + return None; + } + + active_permission_profile.map(permissions_selection_from_active_profile) } fn permissions_selection_from_config( @@ -1228,6 +1192,7 @@ fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), + workspace_roots: thread_start_workspace_roots_from_config(config, thread_params_mode), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1248,24 +1213,16 @@ fn thread_resume_params_from_config( remote_cwd_override: Option<&std::path::Path>, ) -> ThreadResumeParams { let permissions = permissions_selection_from_config(&config, thread_params_mode); - let sandbox = permissions - .is_none() - .then(|| { - sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), - config.cwd.as_path(), - ) - }) - .flatten(); ThreadResumeParams { thread_id: thread_id.to_string(), model: config.model.clone(), model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), + workspace_roots: None, approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), - sandbox, + sandbox: None, permissions, config: config_request_overrides_from_config(&config), persist_extended_history: false, @@ -1280,15 +1237,6 @@ fn thread_fork_params_from_config( remote_cwd_override: Option<&std::path::Path>, ) -> ThreadForkParams { let permissions = permissions_selection_from_config(&config, thread_params_mode); - let sandbox = permissions - .is_none() - .then(|| { - sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), - config.cwd.as_path(), - ) - }) - .flatten(); ThreadForkParams { thread_id: thread_id.to_string(), model: config.model.clone(), @@ -1297,7 +1245,7 @@ fn thread_fork_params_from_config( cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), - sandbox, + sandbox: None, permissions, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), @@ -1309,6 +1257,16 @@ fn thread_fork_params_from_config( } } +fn thread_start_workspace_roots_from_config( + config: &Config, + thread_params_mode: ThreadParamsMode, +) -> Option> { + match thread_params_mode { + ThreadParamsMode::Embedded => Some(config.workspace_roots.clone()), + ThreadParamsMode::Remote => None, + } +} + fn thread_cwd_from_config( config: &Config, thread_params_mode: ThreadParamsMode, @@ -1602,6 +1560,7 @@ mod tests { ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); + assert_eq!(params.workspace_roots, Some(config.workspace_roots.clone())); assert_eq!(params.sandbox, None); assert_eq!( params.permissions, @@ -1631,59 +1590,33 @@ mod tests { #[test] fn embedded_turn_permissions_use_active_profile_selection() { - let cwd = test_path_buf("/workspace/project").abs(); let active_permission_profile = ActivePermissionProfile::new(":workspace"); let expected_permissions = permissions_selection_from_active_profile(active_permission_profile.clone()); - let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::workspace_write(), - Some(active_permission_profile), - cwd.as_path(), - ThreadParamsMode::Embedded, - ); + let permissions = + turn_permissions_selection(Some(active_permission_profile), ThreadParamsMode::Embedded); - assert_eq!(sandbox_policy, None); assert_eq!(permissions, Some(expected_permissions)); } #[test] - fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() { - let cwd = test_path_buf("/workspace/project").abs(); - - let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::read_only(), + fn embedded_turn_permissions_omit_overrides_without_active_profile() { + let permissions = turn_permissions_selection( /*active_permission_profile*/ None, - cwd.as_path(), ThreadParamsMode::Embedded, ); - assert_eq!( - sandbox_policy, - Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - network_access: false - }) - ); assert_eq!(permissions, None); } #[test] - fn remote_turn_permissions_use_sandbox_even_with_active_profile() { - let cwd = test_path_buf("/workspace/project").abs(); - - let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::read_only(), + fn remote_turn_permissions_omit_overrides_even_with_active_profile() { + let permissions = turn_permissions_selection( Some(ActivePermissionProfile::new(":read-only")), - cwd.as_path(), ThreadParamsMode::Remote, ); - assert_eq!( - sandbox_policy, - Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - network_access: false - }) - ); assert_eq!(permissions, None); } @@ -1719,12 +1652,15 @@ mod tests { assert_eq!(start.cwd, None); assert_eq!(resume.cwd, None); assert_eq!(fork.cwd, None); + assert_eq!(start.workspace_roots, None); + assert_eq!(resume.workspace_roots, None); + assert_eq!(fork.workspace_roots, None); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); assert_eq!(start.sandbox, expected_sandbox); - assert_eq!(resume.sandbox, expected_sandbox); - assert_eq!(fork.sandbox, expected_sandbox); + assert_eq!(resume.sandbox, None); + assert_eq!(fork.sandbox, None); assert_eq!(start.permissions, None); assert_eq!(resume.permissions, None); assert_eq!(fork.permissions, None); @@ -1826,12 +1762,15 @@ mod tests { assert_eq!(start.cwd.as_deref(), Some("repo/on/server")); assert_eq!(resume.cwd.as_deref(), Some("repo/on/server")); assert_eq!(fork.cwd.as_deref(), Some("repo/on/server")); + assert_eq!(start.workspace_roots, None); + assert_eq!(resume.workspace_roots, None); + assert_eq!(fork.workspace_roots, None); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); assert_eq!(start.sandbox, expected_sandbox); - assert_eq!(resume.sandbox, expected_sandbox); - assert_eq!(fork.sandbox, expected_sandbox); + assert_eq!(resume.sandbox, None); + assert_eq!(fork.sandbox, None); assert_eq!(start.permissions, None); assert_eq!(resume.permissions, None); assert_eq!(fork.permissions, None); @@ -1989,6 +1928,7 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp/project").abs(), + workspace_roots: Vec::new(), instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ba7460ca47..0d2809eb79 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -151,7 +151,6 @@ mod npm_registry; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; -mod permission_compat; pub(crate) mod public_widgets; mod render; mod resize_reflow_cap; diff --git a/codex-rs/tui/src/permission_compat.rs b/codex-rs/tui/src/permission_compat.rs deleted file mode 100644 index 93dc40e19f..0000000000 --- a/codex-rs/tui/src/permission_compat.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Compatibility projections from the canonical permission profile model into -//! legacy shapes still required by older or remote app-server APIs. - -use codex_protocol::models::PermissionProfile; -use codex_utils_absolute_path::AbsolutePathBuf; -use std::path::Path; - -pub(crate) fn legacy_compatible_permission_profile( - permission_profile: &PermissionProfile, - cwd: &Path, -) -> PermissionProfile { - if permission_profile.to_legacy_sandbox_policy(cwd).is_ok() { - return permission_profile.clone(); - } - - let file_system_policy = permission_profile.file_system_sandbox_policy(); - let network_policy = permission_profile.network_sandbox_policy(); - let cwd_abs = AbsolutePathBuf::from_absolute_path(cwd).ok(); - let writable_roots = file_system_policy - .get_writable_roots_with_cwd(cwd) - .into_iter() - .map(|root| root.root) - .filter(|root| cwd_abs.as_ref() != Some(root)) - .collect::>(); - let tmpdir_writable = std::env::var_os("TMPDIR") - .filter(|tmpdir| !tmpdir.is_empty()) - .and_then(|tmpdir| { - AbsolutePathBuf::from_absolute_path(std::path::PathBuf::from(tmpdir)).ok() - }) - .is_some_and(|tmpdir| file_system_policy.can_write_path_with_cwd(tmpdir.as_path(), cwd)); - let slash_tmp = Path::new("/tmp"); - let slash_tmp_writable = slash_tmp.is_absolute() - && slash_tmp.is_dir() - && file_system_policy.can_write_path_with_cwd(slash_tmp, cwd); - - PermissionProfile::workspace_write_with( - &writable_roots, - network_policy, - !tmpdir_writable, - !slash_tmp_writable, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; - use pretty_assertions::assert_eq; - - #[test] - fn compatibility_profile_preserves_unbridgeable_write_roots() { - let cwd = AbsolutePathBuf::try_from("/workspace/project").expect("absolute cwd"); - let extra_root = AbsolutePathBuf::try_from("/workspace/extra").expect("absolute root"); - let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: extra_root.clone(), - }, - access: FileSystemAccessMode::Write, - }, - ], - glob_scan_max_depth: None, - }, - } - .into(); - - let compatibility_profile = - legacy_compatible_permission_profile(&permission_profile, cwd.as_path()); - let policy = compatibility_profile - .to_legacy_sandbox_policy(cwd.as_path()) - .expect("compatibility profile should project to legacy policy"); - let roots = policy - .get_writable_roots_with_cwd(cwd.as_path()) - .into_iter() - .map(|root| root.root) - .collect::>(); - - assert_eq!(roots, vec![extra_root, cwd]); - } -} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index aa98a20bc4..2f6fd4d801 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -15,7 +15,6 @@ use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_sandbox_summary::summarize_permission_profile; @@ -567,25 +566,6 @@ fn status_permissions_label( approval: &str, ) -> String { let active_id = active_permission_profile.map(|active| active.id.as_str()); - let writable_root_modifications = active_permission_profile - .map(|active| { - active - .modifications - .iter() - .filter(|modification| { - matches!( - modification, - ActivePermissionProfileModification::AdditionalWritableRoot { .. } - ) - }) - .count() - }) - .unwrap_or(0); - let modification_suffix = match writable_root_modifications { - 0 => String::new(), - 1 => " + 1 writable root".to_string(), - count => format!(" + {count} writable roots"), - }; match active_id { Some(":read-only") => { let label = if sandbox == "read-only with network access" { @@ -593,15 +573,16 @@ fn status_permissions_label( } else { "Read Only" }; - return format!("{label}{modification_suffix} ({approval})"); + return format!("{label} ({approval})"); + } + Some(":workspace") => { + let label = if sandbox.contains("network access") { + "Workspace with network access" + } else { + "Workspace" + }; + return format!("{label} ({approval})"); } - Some(":workspace") => match sandbox { - "workspace" => return format!("Workspace{modification_suffix} ({approval})"), - "workspace with network access" => { - return format!("Workspace with network access{modification_suffix} ({approval})"); - } - _ => {} - }, Some(":danger-no-sandbox") if permission_profile == &PermissionProfile::Disabled => { return if approval_policy == AskForApproval::Never { "Full Access".to_string() @@ -609,7 +590,7 @@ fn status_permissions_label( format!("No Sandbox ({approval})") }; } - Some(id) => return format!("Profile {id}{modification_suffix} ({sandbox}, {approval})"), + Some(id) => return format!("Profile {id} ({sandbox}, {approval})"), None => {} } diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b511957d84..30aa65f0e2 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -30,7 +30,6 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -307,7 +306,7 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() { } #[tokio::test] -async fn status_permissions_read_only_profile_shows_additional_writable_roots() { +async fn status_permissions_read_only_profile_ignores_workspace_root_metadata() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config @@ -326,19 +325,13 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() &file_system_policy, NetworkSandboxPolicy::Restricted, ), - Some( - ActivePermissionProfile::new(":read-only").with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, - }, - ]), - ), + Some(ActivePermissionProfile::new(":read-only")), ) .expect("set permission profile"); assert_eq!( permissions_text_for(&config).as_deref(), - Some("Read Only + 1 writable root (on-request)") + Some("Read Only (on-request)") ); } @@ -390,7 +383,7 @@ async fn status_permissions_workspace_auto_review_shows_reviewer_label() { } #[tokio::test] -async fn status_permissions_named_profile_shows_additional_writable_roots() { +async fn status_permissions_named_workspace_profile_uses_builtin_label() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config @@ -408,19 +401,13 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ), - Some( - ActivePermissionProfile::new(":workspace").with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, - }, - ]), - ), + Some(ActivePermissionProfile::new(":workspace")), ) .expect("set permission profile"); assert_eq!( permissions_text_for(&config).as_deref(), - Some("Workspace + 1 writable root (on-request)") + Some("Workspace (on-request)") ); } diff --git a/codex-rs/utils/sandbox-summary/Cargo.toml b/codex-rs/utils/sandbox-summary/Cargo.toml index 758d779781..d8cf3d575b 100644 --- a/codex-rs/utils/sandbox-summary/Cargo.toml +++ b/codex-rs/utils/sandbox-summary/Cargo.toml @@ -13,7 +13,6 @@ codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } [dev-dependencies] -codex-utils-absolute-path = { workspace = true } pretty_assertions = { workspace = true } [lib] diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index 0719773aad..8fa9080dd9 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -21,7 +21,6 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { summary } SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -36,11 +35,6 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { if !*exclude_tmpdir_env_var { writable_entries.push("$TMPDIR".to_string()); } - writable_entries.extend( - writable_roots - .iter() - .map(|p| p.to_string_lossy().to_string()), - ); summary.push_str(&format!(" [{}]", writable_entries.join(", "))); if *network_access { @@ -67,7 +61,6 @@ pub fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: #[cfg(test)] mod tests { use super::*; - use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[test] @@ -96,20 +89,14 @@ mod tests { #[test] fn workspace_write_summary_still_includes_network_access() { - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; - let writable_root = AbsolutePathBuf::try_from(root).unwrap(); let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root.clone()], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }); assert_eq!( summary, - format!( - "workspace-write [workdir, {}] (network access enabled)", - writable_root.to_string_lossy() - ) + "workspace-write [workdir] (network access enabled)" ); } } diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 31e563f4a8..51613bed06 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -29,7 +29,6 @@ chrono = { version = "0.4.42", default-features = false, features = [ "std", ] } codex-utils-pty = { workspace = true } -codex-utils-absolute-path = { workspace = true } codex-utils-string = { workspace = true } codex-otel = { workspace = true } dunce = "1.0" diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 5b6d0d617a..83c766a804 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -5,6 +5,8 @@ use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; +const PROTECTED_WRITE_SUBDIRS: &[&str] = &[".git", ".codex", ".agents"]; + #[derive(Debug, Default, PartialEq, Eq)] pub struct AllowDenyPaths { pub allow: HashSet, @@ -44,19 +46,11 @@ pub fn compute_allow_paths( policy_cwd: &Path, add_allow: &mut dyn FnMut(PathBuf), add_deny: &mut dyn FnMut(PathBuf)| { - let candidate = if root.is_absolute() { - root - } else { - policy_cwd.join(root) - }; - let canonical = canonicalize(&candidate).unwrap_or(candidate); + let canonical = canonical_writable_root(root, policy_cwd); add_allow(canonical.clone()); - for protected_subdir in [".git", ".codex", ".agents"] { - let protected_entry = canonical.join(protected_subdir); - if protected_entry.exists() { - add_deny(protected_entry); - } + for protected_entry in protected_child_deny_paths_for_canonical_root(&canonical) { + add_deny(protected_entry); } }; @@ -66,17 +60,6 @@ pub fn compute_allow_paths( &mut add_allow_path, &mut add_deny_path, ); - - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - for root in writable_roots { - add_writable_root( - root.clone().into(), - policy_cwd, - &mut add_allow_path, - &mut add_deny_path, - ); - } - } } if include_tmp_env_vars { for key in ["TEMP", "TMP"] { @@ -92,24 +75,53 @@ pub fn compute_allow_paths( AllowDenyPaths { allow, deny } } +pub(crate) fn protected_child_deny_paths_for_roots( + roots: I, + policy_cwd: &Path, +) -> HashSet +where + I: IntoIterator, + P: AsRef, +{ + roots + .into_iter() + .flat_map(|root| { + let canonical = canonical_writable_root(root.as_ref().to_path_buf(), policy_cwd); + protected_child_deny_paths_for_canonical_root(&canonical) + }) + .collect() +} + +fn canonical_writable_root(root: PathBuf, policy_cwd: &Path) -> PathBuf { + let candidate = if root.is_absolute() { + root + } else { + policy_cwd.join(root) + }; + canonicalize(&candidate).unwrap_or(candidate) +} + +fn protected_child_deny_paths_for_canonical_root(canonical: &Path) -> Vec { + PROTECTED_WRITE_SUBDIRS + .iter() + .map(|protected_subdir| canonical.join(protected_subdir)) + .filter(|protected_entry| protected_entry.exists()) + .collect() +} + #[cfg(test)] mod tests { use super::*; - use codex_protocol::protocol::SandboxPolicy; - use codex_utils_absolute_path::AbsolutePathBuf; use std::fs; use tempfile::TempDir; #[test] - fn includes_additional_writable_roots() { + fn includes_command_cwd_as_writable_root() { let tmp = TempDir::new().expect("tempdir"); let command_cwd = tmp.path().join("workspace"); - let extra_root = tmp.path().join("extra"); let _ = fs::create_dir_all(&command_cwd); - let _ = fs::create_dir_all(&extra_root); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from(extra_root.as_path()).unwrap()], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -122,11 +134,6 @@ mod tests { .allow .contains(&dunce::canonicalize(&command_cwd).unwrap()) ); - assert!( - paths - .allow - .contains(&dunce::canonicalize(&extra_root).unwrap()) - ); assert!(paths.deny.is_empty(), "no deny paths expected"); } @@ -139,7 +146,6 @@ mod tests { let _ = fs::create_dir_all(&temp_dir); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -170,7 +176,6 @@ mod tests { let _ = fs::create_dir_all(&git_dir); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -197,7 +202,6 @@ mod tests { let _ = fs::write(&git_file, "gitdir: .git/worktrees/example"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -225,7 +229,6 @@ mod tests { let _ = fs::create_dir_all(&agents_dir); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -253,7 +256,6 @@ mod tests { let _ = fs::create_dir_all(&command_cwd); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index c45e3341b7..e0fc404203 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -256,15 +256,10 @@ pub fn apply_capability_denies_for_world_writable( let caps = load_or_create_cap_sids(codex_home)?; std::fs::write(&cap_path, serde_json::to_string(&caps)?)?; let (active_sid, workspace_roots): (*mut c_void, Vec) = match sandbox_policy { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { let sid = unsafe { convert_string_sid_to_sid(&caps.workspace) } .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for workspace capability"))?; - let mut roots: Vec = - vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())]; - for root in writable_roots { - let candidate = root.as_path(); - roots.push(dunce::canonicalize(candidate).unwrap_or_else(|_| root.to_path_buf())); - } + let roots = vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())]; (sid, roots) } SandboxPolicy::ReadOnly { .. } => ( diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index be9f1cfb98..98745b7655 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -194,7 +194,6 @@ mod windows_impl { fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 6d1d8b2135..4279f76080 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -651,7 +651,6 @@ mod windows_impl { fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index ef952a4e03..f870b6607f 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -35,6 +35,8 @@ use windows_sys::Win32::Security::CheckTokenMembership; use windows_sys::Win32::Security::FreeSid; use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; +use crate::allow::protected_child_deny_paths_for_roots; + pub const SETUP_VERSION: u32 = 5; pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; @@ -168,7 +170,7 @@ fn run_setup_refresh_inner( return Ok(()); } let (read_roots, write_roots) = build_payload_roots(&request, &overrides); - let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths); + let deny_write_paths = build_payload_deny_write_paths(&request, &overrides, &write_roots); let network_identity = SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced); let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); @@ -355,7 +357,7 @@ fn gather_helper_read_roots(codex_home: &Path) -> Vec { fn gather_legacy_full_read_roots( command_cwd: &Path, - policy: &SandboxPolicy, + _policy: &SandboxPolicy, codex_home: &Path, ) -> Vec { let mut roots = gather_helper_read_roots(codex_home); @@ -368,11 +370,6 @@ fn gather_legacy_full_read_roots( roots.extend(profile_read_roots(Path::new(&up))); } roots.push(command_cwd.to_path_buf()); - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - for root in writable_roots { - roots.push(root.to_path_buf()); - } - } canonical_existing(&roots) } @@ -720,7 +717,7 @@ pub fn run_elevated_setup( ) })?; let (read_roots, write_roots) = build_payload_roots(&request, &overrides); - let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths); + let deny_write_paths = build_payload_deny_write_paths(&request, &overrides, &write_roots); let network_identity = SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced); let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); @@ -794,7 +791,8 @@ fn build_payload_roots( fn build_payload_deny_write_paths( request: &SandboxSetupRequest<'_>, - explicit_deny_write_paths: Option>, + overrides: &SetupRootOverrides, + write_roots: &[PathBuf], ) -> Vec { let allow_deny_paths: AllowDenyPaths = compute_allow_paths( request.policy, @@ -802,10 +800,13 @@ fn build_payload_deny_write_paths( request.command_cwd, request.env_map, ); - let mut deny_write_paths: Vec = explicit_deny_write_paths + let mut deny_write_paths: Vec = overrides + .deny_write_paths + .as_deref() .unwrap_or_default() - .into_iter() + .iter() .map(|path| { + let path = path.to_path_buf(); if path.exists() { dunce::canonicalize(&path).unwrap_or(path) } else { @@ -814,6 +815,12 @@ fn build_payload_deny_write_paths( }) .collect(); deny_write_paths.extend(allow_deny_paths.deny); + if overrides.write_roots.is_some() { + deny_write_paths.extend(protected_child_deny_paths_for_roots( + write_roots, + request.policy_cwd, + )); + } deny_write_paths } @@ -952,7 +959,6 @@ mod tests { use super::proxy_ports_from_env; use crate::helper_materialization::helper_bin_dir; use crate::policy::SandboxPolicy; - use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::collections::HashSet; @@ -1276,26 +1282,19 @@ mod tests { } #[test] - fn workspace_write_roots_remain_readable() { + fn workspace_write_cwd_remains_readable() { let tmp = TempDir::new().expect("tempdir"); let codex_home = tmp.path().join("codex-home"); let command_cwd = tmp.path().join("workspace"); - let writable_root = tmp.path().join("extra-write-root"); fs::create_dir_all(&command_cwd).expect("create workspace"); - fs::create_dir_all(&writable_root).expect("create writable root"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::from_absolute_path(&writable_root) - .expect("absolute writable root"), - ], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let roots = gather_read_roots(&command_cwd, &policy, &codex_home); - let expected_writable = - dunce::canonicalize(&writable_root).expect("canonical writable root"); + let expected_writable = dunce::canonicalize(&command_cwd).expect("canonical command cwd"); assert!(roots.contains(&expected_writable)); } @@ -1406,10 +1405,6 @@ mod tests { fs::create_dir_all(&command_git).expect("create command .git"); fs::create_dir_all(&extra_codex).expect("create extra .codex"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::from_absolute_path(&extra_write_root) - .expect("absolute writable root"), - ], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1423,8 +1418,14 @@ mod tests { proxy_enforced: false, }; + let overrides = super::SetupRootOverrides { + write_roots: Some(vec![command_cwd.clone(), extra_write_root.clone()]), + deny_write_paths: Some(vec![explicit_deny.clone()]), + ..Default::default() + }; + let (_, write_roots) = super::build_payload_roots(&request, &overrides); let deny_write_paths = - super::build_payload_deny_write_paths(&request, Some(vec![explicit_deny.clone()])); + super::build_payload_deny_write_paths(&request, &overrides, &write_roots); assert_eq!( [ diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index 1b3704d6a2..8c275d5430 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -350,7 +350,6 @@ mod tests { fn no_network_env_rewrite_skips_when_network_access_is_allowed() { assert!(!should_apply_network_block( &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false,