From ab151d008dcc4735411f88c679dff4b9b0f028b6 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 8 May 2026 09:22:02 -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 | 3 +- .../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 | 254 ++++++++++++- .../thread_processor_tests.rs | 2 + .../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 | 1 + .../tests/suite/v2/thread_unarchive.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 46 +-- .../tests/suite/v2/turn_start_zsh_fork.rs | 11 +- codex-rs/config/src/config_requirements.rs | 4 - codex-rs/core/src/agent/control.rs | 71 ++-- codex-rs/core/src/codex_thread.rs | 14 +- .../core/src/config/config_loader_tests.rs | 6 +- codex-rs/core/src/config/config_tests.rs | 72 ++-- 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 | 1 + codex-rs/core/src/context_manager/updates.rs | 7 +- 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 | 8 + codex-rs/core/src/session/session.rs | 44 ++- codex-rs/core/src/session/tests.rs | 53 ++- codex-rs/core/src/session/turn_context.rs | 11 +- .../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 + .../core/tests/suite/request_permissions.rs | 1 - codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec/src/lib.rs | 29 +- codex-rs/exec/src/lib_tests.rs | 22 +- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/exec/tests/suite/sandbox.rs | 20 +- codex-rs/file-system/src/lib.rs | 22 +- 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 | 38 +- 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 | 3 + 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 | 117 +++--- codex-rs/state/src/extract.rs | 5 + 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 | 28 +- 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/src/allow.rs | 26 +- 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 - .../src/setup_orchestrator.rs | 20 +- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 1 - 135 files changed, 1795 insertions(+), 1972 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 10b5cc2351..0b84570781 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3446,6 +3446,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-state", + "codex-utils-absolute-path", "codex-utils-path", "codex-utils-string", "pretty_assertions", @@ -3637,6 +3638,7 @@ dependencies = [ "codex-protocol", "codex-rollout", "codex-state", + "codex-utils-absolute-path", "pretty_assertions", "serde", "serde_json", @@ -3955,7 +3957,6 @@ dependencies = [ "codex-core", "codex-model-provider-info", "codex-protocol", - "codex-utils-absolute-path", "pretty_assertions", ] diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 880adfc254..6fd910646b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -170,6 +170,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, @@ -226,6 +227,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 3021d558d6..2f5814c59a 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -108,6 +108,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, @@ -125,6 +126,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, @@ -142,6 +144,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 fe3738c887..65ee575185 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1845,31 +1845,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": { @@ -1884,20 +1859,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" @@ -3117,13 +3083,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -3390,7 +3349,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "type": [ @@ -3801,7 +3761,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "type": [ @@ -4181,7 +4142,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 156f6ddc4a..5a8a208486 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 @@ -5534,14 +5534,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": [ @@ -5549,31 +5541,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", @@ -11655,31 +11622,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": { @@ -11694,20 +11636,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" @@ -14308,13 +14241,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -15302,7 +15228,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "type": [ @@ -16805,7 +16732,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "type": [ @@ -17847,7 +17775,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 3c5eb030c5..464c272c1d 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", @@ -8248,31 +8215,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": { @@ -8287,20 +8229,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" @@ -10901,13 +10834,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -13170,7 +13096,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "type": [ @@ -14673,7 +14600,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "type": [ @@ -15715,7 +15643,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 3cd919cb9f..03004573a7 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"; @@ -255,7 +254,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 87716e0c9a..d66f292bb6 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2221,6 +2221,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, @@ -2265,6 +2266,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 da0ad2c10e..dd504aab96 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, @@ -3409,6 +3406,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 01982d7ee5..55fd71f98e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,7 +88,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. @@ -143,8 +143,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. @@ -168,7 +168,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"`. @@ -242,6 +242,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: @@ -279,7 +280,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. @@ -647,19 +648,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 cfd2589df1..c44c8c451c 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -100,7 +100,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; @@ -352,6 +351,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 ef44a2b178..386a02388b 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -588,11 +588,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(); @@ -608,6 +610,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 615e37f2c9..ee5efdc9c5 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,99 @@ 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, + ) + })); + } + 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, @@ -798,6 +891,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -831,6 +925,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -1148,6 +1243,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 = @@ -1163,6 +1259,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, @@ -1195,6 +1292,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -1208,6 +1306,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 @@ -1224,6 +1324,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>, @@ -2321,6 +2455,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; @@ -2358,9 +2501,10 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, - sandbox, + sandbox: _sandbox, permissions, config: mut request_overrides, base_instructions, @@ -2388,19 +2532,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, @@ -2409,7 +2602,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 @@ -2421,6 +2614,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(); @@ -2514,6 +2708,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( @@ -2530,6 +2725,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, @@ -2988,6 +3184,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3006,6 +3203,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; @@ -3048,26 +3250,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; @@ -3171,6 +3409,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 = @@ -3186,6 +3425,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 5642dbbe81..422c7ecf26 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 @@ -630,6 +630,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 +651,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 bdc5847b0d..62aeb81ba2 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -354,6 +354,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() @@ -370,52 +371,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; @@ -429,6 +433,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(), @@ -454,6 +461,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 39dae06bd0..473e081b00 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -588,6 +588,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 2b0eafd00a..fcff0c57ce 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1175,6 +1175,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, 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 e5c5c5adbb..ded5f25790 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -330,7 +330,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( 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." + "Exceeded skills context budget of 2%. All skill descriptions were removed and 12 additional skills were not included in the model-visible skills list." ); timeout( @@ -775,7 +775,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() }) @@ -1651,7 +1650,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), @@ -1819,7 +1817,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()?; @@ -1873,7 +1871,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, @@ -1884,14 +1882,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), @@ -1914,7 +1908,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, @@ -1925,9 +1920,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), @@ -3321,7 +3317,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?; @@ -3417,7 +3412,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; @@ -3459,16 +3454,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/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/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 9fe235640a..5e0577691e 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, @@ -240,6 +246,7 @@ impl CodexThread { ) -> ConstraintResult<()> { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -265,6 +272,7 @@ impl CodexThread { let updates = SessionSettingsUpdate { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, 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 9bda54a9a7..0f5db8bc10 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, @@ -3352,14 +3350,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() ); } @@ -3482,13 +3481,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 {}", @@ -6987,6 +6989,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( @@ -7407,6 +7410,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( @@ -7565,6 +7569,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( @@ -7708,6 +7713,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 b1277eda48..bcbd81bc6c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,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; @@ -566,6 +565,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. @@ -1847,6 +1850,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 { @@ -1875,6 +1883,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. @@ -2149,6 +2160,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() { @@ -2236,11 +2248,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( @@ -2282,12 +2293,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!( @@ -2297,6 +2303,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, @@ -2333,10 +2364,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, @@ -2387,28 +2415,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, @@ -2425,22 +2433,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, @@ -2479,25 +2472,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, @@ -2981,6 +2974,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..c1777f4213 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -123,6 +123,7 @@ 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, diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 1bc2cb0895..0b83899128 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -50,14 +50,19 @@ fn build_permissions_update_item( let prev = previous?; if prev.permission_profile() == next.permission_profile() + && prev.workspace_roots == next.workspace_roots && prev.approval_policy == next.approval_policy.value() { return None; } + let permission_profile = next + .permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&next.workspace_roots); Some( PermissionsInstructions::from_permission_profile( - &next.permission_profile, + &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 89c12aaf81..18a436e49e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -617,6 +617,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(), @@ -2613,9 +2614,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..df87ca2429 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -61,6 +61,7 @@ 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(), @@ -102,6 +103,7 @@ 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(), @@ -912,6 +914,7 @@ 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(), @@ -990,6 +993,7 @@ 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(), @@ -1021,6 +1025,7 @@ 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(), @@ -1136,6 +1141,7 @@ 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(), @@ -1250,6 +1256,7 @@ 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(), @@ -1402,6 +1409,7 @@ 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(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index f72a173c80..5c3051e6e2 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, @@ -424,6 +459,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 @@ -446,6 +482,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 @@ -917,6 +954,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 b63b16cbf4..0260d10bb6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1975,6 +1975,7 @@ 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(), @@ -2580,6 +2581,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(), @@ -2684,6 +2686,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(), @@ -2950,6 +2953,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 @@ -3161,6 +3165,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(), @@ -3203,7 +3208,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, @@ -3237,9 +3241,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] @@ -3289,9 +3299,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] @@ -3308,7 +3325,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, }]); @@ -3332,7 +3349,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, @@ -3427,14 +3443,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, @@ -3453,20 +3470,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" ); } @@ -3687,6 +3703,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(), @@ -3795,6 +3812,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, @@ -4022,6 +4040,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, @@ -4124,6 +4143,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, @@ -4770,6 +4790,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, @@ -5165,6 +5186,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 @@ -5510,6 +5532,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 d4fe30063f..a0ed332311 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -70,6 +70,7 @@ pub(crate) 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,6 +355,7 @@ 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(), @@ -411,6 +418,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; @@ -548,6 +556,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 c0d4d17f32..92b4f6a45e 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -246,7 +246,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, @@ -268,7 +267,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 43503be8c1..e7110b2502 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3176,10 +3176,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 c618d778d6..5bfda3603e 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -83,6 +83,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, @@ -234,6 +235,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, @@ -352,6 +354,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 c17247beb4..1073d163d3 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -376,6 +376,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, @@ -398,6 +399,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 c348d76481..dd4f2cdd5a 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -510,6 +510,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); @@ -534,6 +535,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 2538c850e3..fdb7f464c5 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, @@ -2001,7 +2000,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, @@ -2757,7 +2755,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, @@ -3037,7 +3034,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/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..ea9f2c1808 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -29,6 +29,7 @@ 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(), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 34d60dd4aa..12185c09da 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -142,6 +142,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 b035a19517..7b19bad601 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -25,7 +25,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; @@ -82,7 +81,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; @@ -423,6 +421,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, }; @@ -773,6 +772,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, @@ -944,6 +944,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(), @@ -967,6 +968,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa 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(), @@ -986,19 +988,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( @@ -1077,6 +1067,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, ) } @@ -1102,6 +1093,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, ) } @@ -1132,12 +1124,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, @@ -1153,6 +1151,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..89c49e1bf9 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -531,6 +531,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 +578,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/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 30e33abe43..8419b8950e 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. @@ -2707,6 +2703,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)] @@ -2741,6 +2739,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(), @@ -2811,6 +2810,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")] @@ -3451,6 +3452,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, @@ -3499,6 +3504,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, @@ -3516,6 +3523,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, @@ -3531,6 +3543,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, @@ -4480,7 +4493,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 { @@ -4493,13 +4505,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, @@ -5183,6 +5193,7 @@ 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, @@ -5260,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 6b549b745c..4c3dc1c264 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 a8b946934b..5784b1dfce 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, @@ -1340,6 +1342,7 @@ 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, 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 fdfed3dadc..dfcde35058 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1211,6 +1211,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 a8f14fc26a..ef5deefe38 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -415,7 +415,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, @@ -451,7 +450,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, @@ -479,7 +477,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, @@ -769,7 +766,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, @@ -816,15 +812,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. @@ -841,13 +837,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!( @@ -988,13 +986,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) @@ -1024,13 +1024,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) @@ -1071,7 +1073,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, @@ -1123,12 +1124,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", @@ -1140,13 +1142,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) @@ -1176,13 +1180,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) @@ -1220,7 +1226,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 723c5084eb..f0aaf66116 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -247,6 +247,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, @@ -269,6 +270,7 @@ 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, @@ -309,6 +311,7 @@ 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, @@ -343,6 +346,7 @@ 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, @@ -381,6 +385,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 1795a864d8..87cb09d907 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1373,6 +1373,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, @@ -1432,6 +1433,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 6817f677e6..6c7bf40207 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -207,6 +207,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 e470ad2be9..e22df477f4 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 9a76b54168..d2d4847e78 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 9f1e0f8101..9edada82ff 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, @@ -1112,47 +1105,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( @@ -1189,6 +1153,7 @@ fn thread_start_params_from_config( model: config.model.clone(), model_provider: thread_params_mode.model_provider_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, @@ -1209,23 +1174,15 @@ 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), 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, @@ -1240,15 +1197,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(), @@ -1256,7 +1204,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(), @@ -1268,6 +1216,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, @@ -1555,6 +1513,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, @@ -1584,59 +1543,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); } @@ -1672,12 +1605,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); @@ -1779,12 +1715,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); @@ -1870,6 +1809,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 5622c59f65..a81ea39355 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..5fe65c9fcc 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,12 +573,12 @@ fn status_permissions_label( } else { "Read Only" }; - return format!("{label}{modification_suffix} ({approval})"); + return format!("{label} ({approval})"); } Some(":workspace") => match sandbox { - "workspace" => return format!("Workspace{modification_suffix} ({approval})"), + "workspace" => return format!("Workspace ({approval})"), "workspace with network access" => { - return format!("Workspace with network access{modification_suffix} ({approval})"); + return format!("Workspace with network access ({approval})"); } _ => {} }, @@ -609,7 +589,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/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 273dc8c4f2..1ac0fcc7a2 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -66,17 +66,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"] { @@ -95,21 +84,16 @@ pub fn compute_allow_paths( #[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, @@ -120,9 +104,6 @@ mod tests { assert!(paths .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"); } @@ -135,7 +116,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, @@ -162,7 +142,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, @@ -189,7 +168,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, @@ -217,7 +195,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, @@ -245,7 +222,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 9ee371ce68..a15bc36315 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -250,15 +250,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 522f8926d5..fd1f99394e 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -656,7 +656,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_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index ef952a4e03..2b3693845e 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -368,11 +368,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) } @@ -1276,26 +1271,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 +1394,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, 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,