diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e1590e6ce..1cc1034ee9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3397,6 +3397,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-state", + "codex-utils-absolute-path", "codex-utils-path", "codex-utils-string", "pretty_assertions", @@ -3588,6 +3589,7 @@ dependencies = [ "codex-protocol", "codex-rollout", "codex-state", + "codex-utils-absolute-path", "pretty_assertions", "prost 0.14.3", "serde", @@ -3910,7 +3912,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 d2aadc8707..24e3bcf49d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -159,6 +159,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, @@ -213,6 +214,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 14ce570d79..71f747d336 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -106,6 +106,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, @@ -123,6 +124,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, @@ -140,6 +142,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 9ea9893f5b..e12fbca38d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2039,31 +2039,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": { @@ -2078,20 +2053,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" @@ -3228,13 +3194,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -3537,7 +3496,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "anyOf": [ @@ -3948,7 +3908,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "anyOf": [ @@ -4306,7 +4267,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": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 046bab211c..ae54b610f5 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6074,4 +6074,4 @@ } ], "title": "ServerNotification" -} +} \ No newline at end of file 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 dafa256aec..d4de126849 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 @@ -5564,14 +5564,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": [ @@ -5579,31 +5571,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", @@ -11949,31 +11916,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": { @@ -11988,20 +11930,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" @@ -14404,13 +14337,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -15418,7 +15344,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "anyOf": [ @@ -16925,7 +16852,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "anyOf": [ @@ -17978,7 +17906,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": { "anyOf": [ @@ -18553,4 +18481,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file 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 f028f9b3ea..031b94b3da 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", @@ -8560,31 +8527,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": { @@ -8599,20 +8541,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" @@ -11015,13 +10948,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -13304,7 +13230,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "anyOf": [ @@ -14811,7 +14738,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "anyOf": [ @@ -15864,7 +15792,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": { "anyOf": [ @@ -16438,4 +16366,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} +} \ No newline at end of file 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/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 4ec0e10bc9..6909415c2a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1393,4 +1393,4 @@ ], "title": "ItemCompletedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 8684989356..758ceba32d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1393,4 +1393,4 @@ ], "title": "ItemStartedNotification", "type": "object" -} +} \ No newline at end of file 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 970e2fe9ca..be003aa8b8 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" @@ -211,7 +177,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. The server rejects this field because the source permission profile value is preserved across fork." }, "serviceTier": { "anyOf": [ 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 00689feda0..7711ba4ea4 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 9fe5c7f47f..dc822a949f 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" @@ -1098,7 +1064,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. The server rejects this field because the persisted permission profile value is preserved across resume." }, "serviceTier": { "anyOf": [ 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 ff774402b9..393330d76f 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 d5f0e9bfcc..9d5b68d8d0 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 a0f39a29f6..90b7870eae 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 da1320a796..2e1fb02602 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": [ @@ -583,7 +542,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": { "anyOf": [ 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 ba7119e9ed..a537e95d07 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -23,4 +23,8 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * 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}; 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 ac8b1e293b..64ff31f112 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -26,4 +26,8 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * 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 4af17115c8..3d62d6bb82 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -22,7 +22,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 547f0f1018..49405aeab1 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"; @@ -265,7 +264,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 5ab2e5ea01..2f1f720e3f 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2194,6 +2194,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, @@ -2236,6 +2237,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.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3328cd2516..910bf3acd4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -42,7 +42,6 @@ use codex_protocol::mcp::Tool as McpTool; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; 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; @@ -1727,41 +1726,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 { @@ -1769,11 +1733,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -1783,11 +1742,6 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } @@ -1797,26 +1751,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)] @@ -1911,8 +1851,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)] @@ -1982,7 +1920,7 @@ impl<'de> Deserialize<'de> for SandboxPolicy { Ok(SandboxPolicy::ExternalSandbox { network_access }) } SandboxPolicyDeserialize::WorkspaceWrite { - writable_roots, + writable_roots: _writable_roots, read_only_access, network_access, exclude_tmpdir_env_var, @@ -1994,7 +1932,6 @@ impl<'de> Deserialize<'de> for SandboxPolicy { )); } Ok(SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -2024,12 +1961,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, @@ -2056,12 +1991,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, @@ -3797,6 +3730,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, @@ -3806,9 +3744,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, @@ -3882,6 +3819,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, @@ -3951,6 +3893,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, @@ -3958,11 +3905,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, @@ -3997,6 +3946,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, @@ -4057,6 +4011,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, @@ -4064,11 +4023,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, @@ -4103,6 +4064,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, @@ -5800,6 +5766,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)] @@ -5808,13 +5779,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, @@ -10518,7 +10489,6 @@ mod tests { #[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, @@ -10528,7 +10498,6 @@ mod tests { assert_eq!( core_policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -10574,7 +10543,6 @@ mod tests { assert_eq!( policy, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path("/workspace")], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -11829,6 +11797,7 @@ mod tests { 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/README.md b/codex-rs/app-server/README.md index 115fe1c1d2..7c40a373d2 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. @@ -167,7 +167,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"`. @@ -244,6 +244,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: @@ -281,7 +282,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. @@ -635,19 +636,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 45ea709180..c288874b00 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -760,6 +760,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 be6d559866..2be247265a 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -102,7 +102,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; @@ -343,6 +342,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 4a677d91ab..d4088372b0 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -591,11 +591,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); @@ -609,6 +611,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 780c00d1b3..3ba34f8c78 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -16,6 +16,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, @@ -713,6 +806,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -745,6 +839,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -1059,6 +1154,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 = @@ -1074,6 +1170,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, @@ -1106,6 +1203,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -1119,6 +1217,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 @@ -1135,6 +1235,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>, @@ -2199,6 +2333,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; @@ -2228,9 +2371,10 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, - sandbox, + sandbox: _sandbox, permissions, config: mut request_overrides, base_instructions, @@ -2258,19 +2402,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, @@ -2279,7 +2472,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 @@ -2291,6 +2484,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(); @@ -2369,6 +2563,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( @@ -2385,6 +2580,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, @@ -2820,6 +3016,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2837,6 +3034,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; @@ -2879,26 +3081,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; @@ -2984,6 +3222,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 = @@ -2999,6 +3238,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 4f3e476e48..a47b4b97b7 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 @@ -516,6 +516,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)), cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -536,6 +537,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 bb02affcc8..775aa65f3f 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -174,30 +174,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 88ca441845..94172e4a94 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 06b273754c..899200a7fd 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, @@ -218,6 +219,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/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index b95adb9044..00404a19e9 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -668,6 +668,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 ebaba81852..0da1d71146 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 0dc616dc86..49e8cbd797 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1123,6 +1123,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 2e6665e091..bc8fda6446 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1176,6 +1176,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/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 952d6069df..b840c6ba93 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -327,7 +327,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( @@ -706,7 +706,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() }) @@ -1582,7 +1581,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), @@ -1750,7 +1748,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()?; @@ -1804,7 +1802,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, @@ -1815,14 +1813,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), @@ -1845,7 +1839,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, @@ -1856,9 +1851,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), @@ -3252,7 +3248,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?; @@ -3348,7 +3343,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; @@ -3390,16 +3385,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 1a13c0b00c..4d711b2078 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -62,6 +62,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, @@ -219,31 +225,30 @@ 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, - /*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, + /*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()); @@ -701,20 +706,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?; if matches!(thread.agent_status().await, AgentStatus::Shutdown) { Ok(String::new()) } else { - state.send_op(agent_id, Op::Shutdown {}).await + 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 } @@ -736,9 +763,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 cd96ac927f..508935166c 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, @@ -66,11 +67,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(), ) } @@ -80,6 +85,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, @@ -234,6 +240,7 @@ impl CodexThread { ) -> ConstraintResult<()> { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -259,6 +266,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 6fcd5f872d..34550fcae8 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 dfd0fc36d5..4e00470b33 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -61,7 +61,6 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; 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; @@ -90,6 +89,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 { @@ -1000,7 +1007,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, }, @@ -1009,7 +1016,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, @@ -1186,7 +1192,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, @@ -1387,7 +1392,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 @@ -1408,7 +1413,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()?; @@ -1429,18 +1434,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(()) } @@ -1471,7 +1476,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 @@ -1511,7 +1516,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 @@ -1579,7 +1584,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:?}" @@ -1596,12 +1601,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); @@ -1645,7 +1649,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()), @@ -1820,9 +1824,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 @@ -1832,7 +1833,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, @@ -2323,7 +2323,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, @@ -2363,7 +2362,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, @@ -2924,14 +2922,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() ); } @@ -2991,13 +2990,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 {}", @@ -6415,6 +6417,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( @@ -6618,6 +6621,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( @@ -6775,6 +6779,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( @@ -6917,6 +6922,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 7dcc625c0d..81631047c2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -90,7 +90,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -564,6 +563,10 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute roots that define the writable project/workspace set for + /// symbolic `:project_roots` permission entries. + pub workspace_roots: Vec, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -1780,6 +1783,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 { @@ -1808,6 +1816,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. @@ -2132,6 +2143,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() { @@ -2219,11 +2231,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( @@ -2265,12 +2276,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!( @@ -2280,6 +2286,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, @@ -2316,10 +2347,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, @@ -2370,28 +2398,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, @@ -2408,22 +2416,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, @@ -2462,25 +2455,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, @@ -2962,6 +2955,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 db7850008f..20061e26aa 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 28d34bc3c9..e07979f1c5 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 d4ac5c699a..2aa39d7dd2 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 612eaf5d65..5e516e5cae 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -156,6 +156,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), @@ -176,6 +177,7 @@ pub(super) async fn user_input_or_turn_inner( } Op::UserInputWithTurnContext { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -208,6 +210,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 0da9968149..5022b54310 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -616,6 +616,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(), @@ -2573,9 +2574,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 67c1e33310..f2a8812f4e 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -141,6 +141,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 0bb8da6ea6..0c06a83425 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -71,6 +71,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. @@ -106,12 +109,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, @@ -120,13 +124,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(), @@ -137,6 +149,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, @@ -209,6 +222,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(|| { @@ -259,6 +286,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); @@ -292,9 +321,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, @@ -398,6 +433,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 @@ -420,6 +456,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 @@ -904,6 +941,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(), history_log_id, history_entry_count, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 13fd3768bd..3d0cc4b208 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1797,6 +1797,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(), @@ -2402,6 +2403,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(), @@ -2505,6 +2507,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(), @@ -2769,6 +2772,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 @@ -2963,6 +2967,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(), @@ -3004,7 +3009,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, @@ -3038,9 +3042,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] @@ -3090,9 +3100,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] @@ -3109,7 +3126,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, }]); @@ -3133,7 +3150,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, @@ -3228,14 +3244,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, @@ -3254,20 +3271,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" ); } @@ -3488,6 +3504,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(), @@ -3594,6 +3611,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, @@ -3807,6 +3825,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, @@ -4383,6 +4402,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, @@ -4777,6 +4797,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 @@ -5122,6 +5143,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 fe54d61546..3f70a2a51b 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -66,6 +66,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, @@ -100,7 +101,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 { @@ -247,6 +251,7 @@ impl TurnContext { session_source: self.session_source.clone(), 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(), @@ -308,6 +313,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 @@ -343,6 +349,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(), @@ -405,6 +412,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; @@ -539,6 +547,7 @@ impl Session { session_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 04472e4623..4998e8a700 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 0b308bb09e..b1ce67c69a 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 @@ -14,84 +14,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 59a5038934..b0466fa115 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 @@ -16,111 +16,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 bc5dcd6929..857918f4fe 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -21,157 +21,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 88c1698d8f..7a3c2e1fcc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3175,10 +3175,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 .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 c0a1bcbc53..72d45c20d7 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 @@ -14,96 +14,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 184ea36c51..a08eea36e3 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 @@ -23,188 +23,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 fb330fa913..d4eab49de4 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -78,6 +78,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 14f652f58b..5196901d8f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,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); @@ -541,6 +542,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 96cc1f3a99..d55ac91e4b 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 bb93d5cbf8..ad07af3267 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -574,9 +574,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 68121d2e12..47d9e6da7c 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, @@ -108,6 +109,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 8250f5493d..0162dc8899 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -141,6 +141,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { forked_from_id: None, timestamp: "2026-01-27T12:00:00Z".to_string(), cwd: codex_home.to_path_buf(), + workspace_roots: Vec::new(), originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 8f084a50e9..19d152213e 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -100,6 +100,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 d61346f1d0..167d097ff2 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; @@ -80,7 +79,6 @@ use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; 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; @@ -420,6 +418,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, }; @@ -770,6 +769,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, @@ -941,6 +941,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(), @@ -964,6 +965,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(), @@ -983,19 +985,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( @@ -1142,7 +1132,8 @@ fn session_configured_from_thread_response( approvals_reviewer, permission_profile, active_permission_profile, - cwd, + cwd: cwd.clone(), + workspace_roots: vec![cwd], reasoning_effort, history_log_id: 0, history_entry_count: 0, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 094da6f936..efc0f46204 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -487,11 +487,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 4b01ccccd1..62bf0eb935 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -117,6 +117,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, history_log_id: 0, history_entry_count: 0, 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 eb66ea0619..0e1de79d75 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -307,6 +307,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()), history_log_id: 1, history_entry_count: 1000, @@ -352,6 +353,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()), history_log_id: 1, history_entry_count: 1000, @@ -420,6 +422,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()), history_log_id: 1, history_entry_count: 1000, 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..d5cfb6ef81 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 => { @@ -1057,16 +1075,12 @@ impl FileSystemSandboxPolicy { 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() + || unbridgeable_external_write || tmpdir_writable || slash_tmp_writable { @@ -1135,12 +1149,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 +1460,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 +1499,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 +1506,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) } @@ -1800,7 +1794,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 +1949,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 +2464,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 +2490,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 3675806e60..b9ea5b5783 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -466,6 +466,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, @@ -1051,14 +1055,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)] @@ -1168,7 +1167,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, @@ -1206,13 +1204,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. @@ -2724,6 +2720,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)] @@ -2755,6 +2753,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(), @@ -2824,6 +2823,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")] @@ -3500,6 +3501,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, @@ -3550,6 +3555,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { #[serde(default)] active_permission_profile: Option, cwd: AbsolutePathBuf, + #[serde(default)] + workspace_roots: Vec, reasoning_effort: Option, history_log_id: u64, history_entry_count: usize, @@ -3569,6 +3576,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, @@ -3582,6 +3594,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, history_log_id: wire.history_log_id, history_entry_count: wire.history_entry_count, @@ -4563,7 +4576,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 { @@ -4576,13 +4588,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, @@ -5266,6 +5276,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, @@ -5340,6 +5351,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()), history_log_id: 0, history_entry_count: 0, 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 c94cd0be7e..960824b275 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(), @@ -88,6 +89,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(), @@ -347,6 +349,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 512c223263..30674fbb89 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -689,6 +689,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 5711c47bad..d7d4cdeedc 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, @@ -1190,6 +1192,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 fbef7eb4f9..3d3e0c9099 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 b5c2790dae..a921746759 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 a4a0ab0f6a..31b87abd51 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -275,6 +275,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, @@ -296,6 +297,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, @@ -336,6 +338,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, @@ -370,6 +373,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, @@ -408,6 +412,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 5188bc3bc0..5c122e335e 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1358,6 +1358,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, @@ -1416,6 +1417,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 f350bce2cd..90c7c55412 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -202,6 +202,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_keymap: TuiKeymap::default(), 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 3a0428f20e..2c0478ce80 100644 --- a/codex-rs/thread-store/Cargo.toml +++ b/codex-rs/thread-store/Cargo.toml @@ -22,6 +22,7 @@ codex-git-utils = { workspace = true } codex-protocol = { workspace = true } codex-rollout = { workspace = true } codex-state = { workspace = true } +codex-utils-absolute-path = { workspace = true } prost = "0.14.3" serde = { workspace = true, features = ["derive"] } serde_json = { 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 e444e5c91d..5f88bc76a0 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 058c7b2309..b9e294c075 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, }, @@ -748,6 +749,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 98ee788662..272d8229bc 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -1023,6 +1023,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/remote/mod.rs b/codex-rs/thread-store/src/remote/mod.rs index 3e74a45f4b..e4be70ab46 100644 --- a/codex-rs/thread-store/src/remote/mod.rs +++ b/codex-rs/thread-store/src/remote/mod.rs @@ -349,6 +349,7 @@ mod tests { let (store, mut requests_rx) = test_store().await; let metadata = ThreadPersistenceMetadata { cwd: Some(PathBuf::from("/workspace")), + workspace_roots: Vec::new(), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, }; @@ -381,6 +382,7 @@ mod tests { let (store, mut requests_rx) = test_store().await; let metadata = ThreadPersistenceMetadata { cwd: Some(PathBuf::from("/workspace")), + workspace_roots: Vec::new(), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, }; diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 85bde023bd..6c86591806 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; use codex_protocol::protocol::TokenUsage; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -33,6 +34,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_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 67e4869868..b5065b69ad 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -36,7 +36,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; @@ -106,7 +105,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; @@ -552,6 +550,7 @@ 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, @@ -1113,19 +1112,7 @@ 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( @@ -1190,6 +1177,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: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1223,6 +1211,7 @@ fn thread_resume_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: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1859,6 +1848,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/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 ade36985c0..658740b92d 100644 --- a/codex-rs/utils/sandbox-summary/Cargo.toml +++ b/codex-rs/utils/sandbox-summary/Cargo.toml @@ -13,5 +13,4 @@ codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } [dev-dependencies] -codex-utils-absolute-path = { workspace = true } pretty_assertions = { workspace = true } 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 2c1c4f79ce..bab55c2169 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -255,7 +255,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,