From 46f2ed235c0b091ebb70ac989f8b19e3fcd00aae Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 28 Apr 2026 18:11:41 -0700 Subject: [PATCH] permissions: expose active profile metadata --- .../analytics/src/analytics_client_tests.rs | 9 +- .../codex_app_server_protocol.schemas.json | 26 +- .../codex_app_server_protocol.v2.schemas.json | 26 +- .../schema/json/v2/ThreadForkResponse.json | 347 ++---------------- .../schema/json/v2/ThreadResumeResponse.json | 347 ++---------------- .../schema/json/v2/ThreadStartResponse.json | 347 ++---------------- .../typescript/v2/ActivePermissionProfile.ts | 15 + .../typescript/v2/ThreadForkResponse.ts | 4 +- .../typescript/v2/ThreadResumeResponse.ts | 4 +- .../typescript/v2/ThreadStartResponse.ts | 4 +- .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 11 +- .../app-server-protocol/src/protocol/v2.rs | 71 +++- .../app-server/src/codex_message_processor.rs | 53 ++- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/config/config_tests.rs | 84 +++++ codex-rs/core/src/config/mod.rs | 38 +- codex-rs/core/src/session/mod.rs | 2 + codex-rs/core/src/session/session.rs | 10 + codex-rs/core/src/session/tests.rs | 7 + codex-rs/exec/src/lib.rs | 89 ++--- codex-rs/exec/src/lib_tests.rs | 21 +- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/protocol/src/models.rs | 28 ++ codex-rs/protocol/src/protocol.rs | 11 + codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/tests.rs | 7 + codex-rs/tui/src/app/thread_events.rs | 1 + codex-rs/tui/src/app/thread_session_state.rs | 22 +- codex-rs/tui/src/app_server_session.rs | 143 +++++--- codex-rs/tui/src/chatwidget.rs | 8 +- .../chatwidget/tests/composer_submission.rs | 9 + .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 9 + .../tui/src/chatwidget/tests/permissions.rs | 2 + .../tui/src/chatwidget/tests/plan_mode.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/history_cell.rs | 1 + codex-rs/tui/src/status/card.rs | 58 ++- codex-rs/tui/src/status/tests.rs | 67 ++++ 41 files changed, 715 insertions(+), 1178 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 352acbe1fc..47199aee9d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -65,7 +65,6 @@ use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NonSteerableTurnKind; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -154,16 +153,12 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) - approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, reasoning_effort: None, }, } } -fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::Disabled.into() -} - fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -210,7 +205,7 @@ fn sample_thread_resume_response_with_source( approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, reasoning_effort: None, }, } 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 1726a603f6..440061b3e3 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 @@ -5365,6 +5365,26 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -14668,7 +14688,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "serviceTier": { "anyOf": [ @@ -16179,7 +16199,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "serviceTier": { "anyOf": [ @@ -16483,7 +16503,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "serviceTier": { "anyOf": [ 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 0e6d461acf..72aa462c8a 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 @@ -130,6 +130,26 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -12554,7 +12574,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "serviceTier": { "anyOf": [ @@ -14065,7 +14085,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "serviceTier": { "anyOf": [ @@ -14369,7 +14389,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "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 b5d6b139b2..f82c1193e9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AgentPath": { "type": "string" }, @@ -450,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -884,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2501,7 +2196,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "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 7135a53174..d378b7c968 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AgentPath": { "type": "string" }, @@ -450,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -884,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2501,7 +2196,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "serviceTier": { "anyOf": [ 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 5deca9f699..d85953b32e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AgentPath": { "type": "string" }, @@ -450,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -884,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2501,7 +2196,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. New clients should use `activePermissionProfile` when they need stable permissions profile identity." }, "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 new file mode 100644 index 0000000000..d6e560d0f1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -0,0 +1,15 @@ +// 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. + +export type ActivePermissionProfile = { +/** + * Identifier from `default_permissions`, such as a built-in `:workspace` + * profile or a user-defined `[permissions.]` profile. + */ +id: string, +/** + * Parent profile identifier once permissions profiles support + * inheritance. This is currently always `null`. + */ +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index 1207002514..aee782d642 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -17,7 +17,7 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * `activePermissionProfile` when they need stable permissions profile + * identity. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 20f91b3e91..abc5e9b1a3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -17,7 +17,7 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * `activePermissionProfile` when they need stable permissions profile + * identity. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 87e15411a2..b0574cb7d2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -17,7 +17,7 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * `activePermissionProfile` when they need stable permissions profile + * identity. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; 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 7aa224b8c6..a202b4b3bf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,6 +4,7 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; +export type { ActivePermissionProfile } from "./ActivePermissionProfile"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5d506d05b0..2c7fd0d503 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1998,12 +1998,7 @@ mod tests { approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, - permission_profile: Some( - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &codex_protocol::protocol::SandboxPolicy::DangerFullAccess, - ) - .into(), - ), + active_permission_profile: None, reasoning_effort: None, }, }; @@ -2046,9 +2041,7 @@ mod tests { "sandbox": { "type": "dangerFullAccess" }, - "permissionProfile": { - "type": "disabled" - }, + "activePermissionProfile": null, "reasoningEffort": null } }), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 40fcf1fc46..6fc9cc009f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -38,6 +38,7 @@ use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; 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::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -1703,6 +1704,37 @@ impl From for CorePermissionProfile { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ActivePermissionProfile { + /// Identifier from `default_permissions`, such as a built-in `:workspace` + /// profile or a user-defined `[permissions.]` profile. + pub id: String, + /// Parent profile identifier once permissions profiles support + /// inheritance. This is currently always `null`. + #[serde(default)] + pub extends: Option, +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3527,13 +3559,14 @@ pub struct ThreadStartResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. New clients should use - /// `permissionProfile` when present as the canonical active permissions - /// view. + /// `activePermissionProfile` when they need stable permissions profile + /// identity. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. - #[experimental("thread/start.permissionProfile")] + /// Named profile that produced the active permissions, when the active + /// permissions came from `default_permissions`. + #[experimental("thread/start.activePermissionProfile")] #[serde(default)] - pub permission_profile: Option, + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3633,13 +3666,14 @@ pub struct ThreadResumeResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. New clients should use - /// `permissionProfile` when present as the canonical active permissions - /// view. + /// `activePermissionProfile` when they need stable permissions profile + /// identity. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. - #[experimental("thread/resume.permissionProfile")] + /// Named profile that produced the active permissions, when the active + /// permissions came from `default_permissions`. + #[experimental("thread/resume.activePermissionProfile")] #[serde(default)] - pub permission_profile: Option, + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3730,13 +3764,14 @@ pub struct ThreadForkResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. New clients should use - /// `permissionProfile` when present as the canonical active permissions - /// view. + /// `activePermissionProfile` when they need stable permissions profile + /// identity. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. - #[experimental("thread/fork.permissionProfile")] + /// Named profile that produced the active permissions, when the active + /// permissions came from `default_permissions`. + #[experimental("thread/fork.activePermissionProfile")] #[serde(default)] - pub permission_profile: Option, + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -10720,9 +10755,9 @@ mod tests { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); + assert_eq!(start.active_permission_profile, None); + assert_eq!(resume.active_permission_profile, None); + assert_eq!(fork.active_permission_profile, None); } #[test] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 362f8692b6..3a5ca399cc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2796,8 +2796,9 @@ impl CodexMessageProcessor { &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), ); - let permission_profile = - thread_response_permission_profile(config_snapshot.permission_profile); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); let response = ThreadStartResponse { thread: thread.clone(), @@ -2809,7 +2810,7 @@ impl CodexMessageProcessor { approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, - permission_profile, + active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; Ok::<_, JSONRPCErrorError>((response, thread_started_notification(thread))) @@ -4347,8 +4348,9 @@ impl CodexMessageProcessor { &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), ); - let permission_profile = - thread_response_permission_profile(config_snapshot.permission_profile.clone()); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); let response = ThreadResumeResponse { thread, @@ -4360,7 +4362,7 @@ impl CodexMessageProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile, + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; self.analytics_events_client.track_response( @@ -4947,8 +4949,9 @@ impl CodexMessageProcessor { &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), ); - let permission_profile = - thread_response_permission_profile(config_snapshot.permission_profile); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); let response = ThreadForkResponse { thread: thread.clone(), @@ -4960,7 +4963,7 @@ impl CodexMessageProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile, + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -8223,13 +8226,15 @@ async fn handle_pending_thread_resume_request( approval_policy, approvals_reviewer, permission_profile, + active_permission_profile, cwd, reasoning_effort, .. } = pending.config_snapshot; let instruction_sources = pending.instruction_sources; let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); - let permission_profile = thread_response_permission_profile(permission_profile); + let active_permission_profile = + thread_response_active_permission_profile(active_permission_profile); let response = ThreadResumeResponse { thread, @@ -8241,7 +8246,7 @@ async fn handle_pending_thread_resume_request( approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, - permission_profile, + active_permission_profile, reasoning_effort, }; let token_usage_thread = pending.include_turns.then(|| response.thread.clone()); @@ -9365,10 +9370,10 @@ fn with_thread_spawn_agent_metadata( } } -fn thread_response_permission_profile( - permission_profile: codex_protocol::models::PermissionProfile, -) -> Option { - Some(permission_profile.into()) +fn thread_response_active_permission_profile( + active_permission_profile: Option, +) -> Option { + active_permission_profile.map(Into::into) } fn thread_response_sandbox_policy( @@ -9912,23 +9917,6 @@ mod tests { ); } - #[test] - fn thread_response_permission_profile_preserves_enforcement() { - let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; - let external_profile = codex_protocol::models::PermissionProfile::External { - network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, - }; - - assert_eq!( - thread_response_permission_profile(external_profile.clone()), - Some(external_profile.into()) - ); - assert_eq!( - thread_response_permission_profile(full_access_profile.clone()), - Some(full_access_profile.into()) - ); - } - #[test] fn requested_permissions_trust_project_uses_permission_profile_intent() { let cwd = test_path_buf("/tmp/project").abs(); @@ -10165,6 +10153,7 @@ mod tests { approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, + active_permission_profile: None, cwd, ephemeral: false, reasoning_effort: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index a2f64e86f4..644b355edd 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -15,6 +15,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseInputItem; @@ -48,6 +49,7 @@ pub struct ThreadConfigSnapshot { pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, + pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, pub ephemeral: bool, pub reasoning_effort: Option, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 097e93baac..7bf3cd778a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -60,6 +60,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; 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::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; @@ -907,6 +908,14 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted ); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some("workspace") + ); Ok(()) } @@ -928,6 +937,7 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: .await?; assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!(config.permissions.active_permission_profile(), None); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::DangerFullAccess @@ -1267,6 +1277,14 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl .await?; let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(":workspace") + ); assert!( policy.can_write_path_with_cwd(cwd.path(), cwd.path()), "expected :workspace to allow writing the project root, policy: {policy:?}" @@ -1278,6 +1296,48 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl Ok(()) } +#[tokio::test] +async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_settings() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":workspace".to_string()), + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + writable_roots: vec![extra_root.path().abs()], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Restricted + ); + assert!( + !policy.entries.iter().any(|entry| matches!( + &entry.path, + FileSystemPath::Path { path } if path.as_path() == extra_root.path() + )), + "explicit :workspace should not inherit sandbox_workspace_write roots as concrete grants, \ + policy: {policy:?}" + ); + Ok(()) +} + #[tokio::test] async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -1303,6 +1363,18 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: .await?; let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(if cfg!(target_os = "windows") { + ":read-only" + } else { + ":workspace" + }) + ); if cfg!(target_os = "windows") { assert!( !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), @@ -1484,6 +1556,14 @@ async fn default_permissions_can_select_builtin_no_sandbox_profile() -> std::io: config.permissions.permission_profile(), PermissionProfile::Disabled ); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(":danger-no-sandbox") + ); Ok(()) } @@ -5894,6 +5974,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6088,6 +6169,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6236,6 +6318,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6369,6 +6452,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7fce4833dc..4357ea8ae0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -83,6 +83,7 @@ use codex_protocol::config_types::Verbosity; 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::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -226,6 +227,9 @@ pub struct Permissions { /// Canonical effective runtime permissions after config requirements and /// runtime readable-root additions have been applied. pub permission_profile: Constrained, + /// Named profile selected by config, when the active permissions came from + /// `default_permissions` rather than an ad-hoc override. + pub active_permission_profile: Option, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -253,6 +257,11 @@ impl Permissions { self.permission_profile.get().clone() } + /// Named profile selected by config, if the current profile has one. + pub fn active_permission_profile(&self) -> Option { + self.active_permission_profile.clone() + } + /// Effective filesystem sandbox policy derived from the canonical profile. pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { self.permission_profile.get().file_system_sandbox_policy() @@ -311,6 +320,7 @@ impl Permissions { ); self.permission_profile.set(permission_profile)?; + self.active_permission_profile = None; Ok(()) } @@ -318,10 +328,24 @@ impl Permissions { pub fn set_permission_profile( &mut self, permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.set_permission_profile_with_active_profile( + permission_profile, + /*active_permission_profile*/ None, + ) + } + + /// Replace permissions from the canonical profile and record the named + /// source profile, if one is known. + pub fn set_permission_profile_with_active_profile( + &mut self, + permission_profile: PermissionProfile, + active_permission_profile: Option, ) -> ConstraintResult<()> { self.permission_profile.can_set(&permission_profile)?; self.permission_profile.set(permission_profile)?; + self.active_permission_profile = active_permission_profile; Ok(()) } } @@ -2007,6 +2031,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + active_permission_profile, ) = if let Some(mut permission_profile) = permission_profile { let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); @@ -2052,11 +2077,17 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + None, ) } else if profiles_are_active { let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| { default_builtin_permission_profile_name(&active_project, windows_sandbox_level) }); + let builtin_workspace_write_settings = if using_implicit_builtin_profile { + cfg.sandbox_workspace_write.as_ref() + } else { + None + }; let configured_network_proxy_config = network_proxy_config_for_profile_selection( cfg.permissions.as_ref(), default_permissions, @@ -2065,12 +2096,12 @@ impl Config { compile_permission_profile_selection( cfg.permissions.as_ref(), default_permissions, - cfg.sandbox_workspace_write.as_ref(), + builtin_workspace_write_settings, resolved_cwd.as_path(), &mut startup_warnings, )?; let mut permission_profile = if let Some(permission_profile) = - builtin_permission_profile(default_permissions, cfg.sandbox_workspace_write.as_ref()) + builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { permission_profile } else { @@ -2106,6 +2137,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + Some(ActivePermissionProfile::new(default_permissions)), ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2167,6 +2199,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + None, ) }; let approval_policy_was_explicit = approval_policy_override.is_some() @@ -2624,6 +2657,7 @@ impl Config { permissions: Permissions { approval_policy: constrained_approval_policy.value, permission_profile: constrained_permission_profile.value, + active_permission_profile, network, allow_login_shell, shell_environment_policy, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 415b89c0de..7f3f6aa49d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -91,6 +91,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; @@ -602,6 +603,7 @@ impl Codex { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 03849f7e07..65d29ae7e5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -60,6 +60,8 @@ pub(crate) struct SessionConfiguration { pub(super) approvals_reviewer: ApprovalsReviewer, /// Canonical permission profile for the session. pub(super) permission_profile: Constrained, + /// Named permissions profile selected from config, if any. + pub(super) active_permission_profile: Option, pub(super) windows_sandbox_level: WindowsSandboxLevel, /// Absolute working directory that should be treated as the *root* of the @@ -97,6 +99,10 @@ impl SessionConfiguration { self.permission_profile.get().clone() } + pub(super) fn active_permission_profile(&self) -> Option { + self.active_permission_profile.clone() + } + pub(super) fn sandbox_policy(&self) -> SandboxPolicy { self.permission_profile() .to_legacy_sandbox_policy(&self.cwd) @@ -127,6 +133,7 @@ impl SessionConfiguration { approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), + active_permission_profile: self.active_permission_profile(), cwd: self.cwd.clone(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), @@ -210,6 +217,7 @@ impl SessionConfiguration { permission_profile, Some(¤t_file_system_sandbox_policy), )?; + next_configuration.active_permission_profile = None; } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( @@ -225,6 +233,7 @@ impl SessionConfiguration { network_sandbox_policy, ), )?; + next_configuration.active_permission_profile = None; } else if cwd_changed && file_system_policy_matches_legacy && file_system_policy_has_rebindable_project_root_write @@ -887,6 +896,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), + active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 381d649920..fab67a8f36 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2323,6 +2323,7 @@ async fn set_rate_limits_retains_previous_credits() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -2425,6 +2426,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -2872,6 +2874,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3308,6 +3311,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3412,6 +3416,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3624,6 +3629,7 @@ async fn make_session_with_config_and_rx( approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4806,6 +4812,7 @@ where approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d4044a728d..2c25d1807b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -82,7 +82,6 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -660,37 +659,24 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // Handle resume subcommand through existing `thread/list` + `thread/resume` // APIs so exec no longer reaches into rollout storage directly. - let (primary_thread_id, fallback_session_configured) = - if let Some(ExecCommand::Resume(args)) = command.as_ref() { - if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { - let response: ThreadResumeResponse = send_request_with_response( - &client, - ClientRequest::ThreadResume { - request_id: request_ids.next(), - params: thread_resume_params_from_config(&config, thread_id), - }, - "thread/resume", - ) - .await - .map_err(anyhow::Error::msg)?; - let session_configured = session_configured_from_thread_resume_response(&response) + let (primary_thread_id, fallback_session_configured) = if let Some(ExecCommand::Resume(args)) = + command.as_ref() + { + if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { + let response: ThreadResumeResponse = send_request_with_response( + &client, + ClientRequest::ThreadResume { + request_id: request_ids.next(), + params: thread_resume_params_from_config(&config, thread_id), + }, + "thread/resume", + ) + .await + .map_err(anyhow::Error::msg)?; + let session_configured = + session_configured_from_thread_resume_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) - } else { - let response: ThreadStartResponse = send_request_with_response( - &client, - ClientRequest::ThreadStart { - request_id: request_ids.next(), - params: thread_start_params_from_config(&config), - }, - "thread/start", - ) - .await - .map_err(anyhow::Error::msg)?; - let session_configured = session_configured_from_thread_start_response(&response) - .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) - } + (session_configured.session_id, session_configured) } else { let response: ThreadStartResponse = send_request_with_response( &client, @@ -702,10 +688,26 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; - let session_configured = session_configured_from_thread_start_response(&response) - .map_err(anyhow::Error::msg)?; + let session_configured = + session_configured_from_thread_start_response(&response, &config) + .map_err(anyhow::Error::msg)?; (session_configured.session_id, session_configured) - }; + } + } else { + let response: ThreadStartResponse = send_request_with_response( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: thread_start_params_from_config(&config), + }, + "thread/start", + ) + .await + .map_err(anyhow::Error::msg)?; + let session_configured = session_configured_from_thread_start_response(&response, &config) + .map_err(anyhow::Error::msg)?; + (session_configured.session_id, session_configured) + }; let primary_thread_id_for_span = primary_thread_id.to_string(); // Use the start/resume response as the authoritative bootstrap payload. @@ -972,6 +974,7 @@ where fn session_configured_from_thread_start_response( response: &ThreadStartResponse, + config: &Config, ) -> Result { session_configured_from_thread_response( &response.thread.id, @@ -982,8 +985,8 @@ fn session_configured_from_thread_start_response( response.service_tier, response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response.sandbox.to_core(), - response.permission_profile.clone().map(Into::into), + config.permissions.permission_profile(), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -991,6 +994,7 @@ fn session_configured_from_thread_start_response( fn session_configured_from_thread_resume_response( response: &ThreadResumeResponse, + config: &Config, ) -> Result { session_configured_from_thread_response( &response.thread.id, @@ -1001,8 +1005,8 @@ fn session_configured_from_thread_resume_response( response.service_tier, response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response.sandbox.to_core(), - response.permission_profile.clone().map(Into::into), + config.permissions.permission_profile(), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -1030,8 +1034,8 @@ fn session_configured_from_thread_response( service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - sandbox_policy: SandboxPolicy, - permission_profile: Option, + permission_profile: PermissionProfile, + active_permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { @@ -1047,9 +1051,8 @@ fn session_configured_from_thread_response( service_tier, approval_policy, approvals_reviewer, - permission_profile: permission_profile.unwrap_or_else(|| { - PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, cwd.as_path()) - }), + permission_profile, + active_permission_profile, cwd, reasoning_effort, history_log_id: 0, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index bcb17fb87d..bceb935d78 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -391,8 +391,16 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() ); } -#[test] -fn session_configured_from_thread_response_uses_review_policy_from_response() { +#[tokio::test] +async fn session_configured_from_thread_response_uses_review_policy_from_response() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); let response = ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), @@ -426,16 +434,11 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, - permission_profile: Some( - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(), - ) - .into(), - ), + active_permission_profile: None, reasoning_effort: None, }; - let event = session_configured_from_thread_start_response(&response) + let event = session_configured_from_thread_start_response(&response, &config) .expect("build bootstrap session configured event"); assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview); 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 23dcd6733a..f6f6d35f21 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -115,6 +115,7 @@ fn session_configured_produces_thread_started_event() { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 78ef4cacff..eb66ea0619 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -305,6 +305,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -349,6 +350,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -416,6 +418,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 2d7dba7d54..e38f0db834 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -317,6 +317,34 @@ pub enum PermissionProfile { External { network: NetworkSandboxPolicy }, } +/// Metadata for the named permissions profile that produced the active +/// `PermissionProfile`. +/// +/// The runtime must honor `PermissionProfile`; this sidecar exists so clients +/// can display stable profile identity without trying to reverse-engineer a +/// name from the compiled permissions. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)] +pub struct ActivePermissionProfile { + /// Profile identifier from `default_permissions`, such as `:workspace` or a + /// user-defined `[permissions.]` profile. + pub id: String, + + /// Optional parent profile identifier once permissions profiles support + /// inheritance. This is always `None` until that config feature exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub extends: Option, +} + +impl ActivePermissionProfile { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + extends: None, + } + } +} + impl Default for PermissionProfile { fn default() -> Self { Self::Managed { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c9ff684f7c..c239cec3eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ use crate::mcp::ResourceTemplate as McpResourceTemplate; use crate::mcp::Tool as McpTool; use crate::memory_citation::MemoryCitation; use crate::message_history::HistoryEntry; +use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::MessagePhase; @@ -3508,6 +3509,12 @@ pub struct SessionConfiguredEvent { /// Canonical effective permissions for commands executed in the session. pub permission_profile: PermissionProfile, + /// Named profile that produced `permission_profile`, when the active + /// permissions came from `default_permissions`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub active_permission_profile: Option, + /// Working directory that should be treated as the *root* of the /// session. pub cwd: AbsolutePathBuf, @@ -3559,6 +3566,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { // and immediately project it into the canonical `permission_profile`. sandbox_policy: Option, permission_profile: Option, + #[serde(default)] + active_permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, history_log_id: u64, @@ -3590,6 +3599,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { approval_policy: wire.approval_policy, approvals_reviewer: wire.approvals_reviewer, permission_profile, + active_permission_profile: wire.active_permission_profile, cwd: wire.cwd, reasoning_effort: wire.reasoning_effort, history_log_id: wire.history_log_id, @@ -5102,6 +5112,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: permission_profile.clone(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 3b5479f1d5..937e0a9fcc 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -648,6 +648,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: next_cwd.clone().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 29f82ab85d..59b247fa52 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3570,6 +3570,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, @@ -3809,6 +3810,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, @@ -4321,6 +4323,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4384,6 +4387,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4477,6 +4481,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4863,6 +4868,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4975,6 +4981,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 10fc370f58..2c4e560afa 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -350,6 +350,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 9b6d7e9726..efe0adf846 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -3,6 +3,7 @@ use crate::app_server_session::ThreadSessionState; use crate::read_session_model; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; impl App { @@ -18,10 +19,16 @@ impl App { .config_ref() .permissions .permission_profile(); + let active_permission_profile = self + .chat_widget + .config_ref() + .permissions + .active_permission_profile(); let update_session = |session: &mut ThreadSessionState| { session.approval_policy = approval_policy; session.approvals_reviewer = approvals_reviewer; session.permission_profile = permission_profile.clone(); + session.active_permission_profile = active_permission_profile.clone(); }; if self.primary_thread_id == Some(active_thread_id) @@ -43,7 +50,8 @@ impl App { thread_id: ThreadId, thread: &Thread, ) -> ThreadSessionState { - let permission_profile = self.active_permission_profile(); + let permission_profile = self.current_permission_profile(); + let active_permission_profile = self.current_active_permission_profile(); let mut session = self .primary_session_configured .clone() @@ -58,6 +66,7 @@ impl App { approval_policy: self.config.permissions.approval_policy.value(), approvals_reviewer: self.config.approvals_reviewer, permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), @@ -71,6 +80,7 @@ impl App { session.model_provider_id = thread.model_provider.clone(); session.cwd = thread.cwd.clone(); session.permission_profile = permission_profile; + session.active_permission_profile = active_permission_profile; session.instruction_source_paths = Vec::new(); session.rollout_path = thread.path.clone(); if let Some(model) = @@ -85,12 +95,19 @@ impl App { session } - fn active_permission_profile(&self) -> PermissionProfile { + fn current_permission_profile(&self) -> PermissionProfile { self.chat_widget .config_ref() .permissions .permission_profile() } + + fn current_active_permission_profile(&self) -> Option { + self.chat_widget + .config_ref() + .permissions + .active_permission_profile() + } } #[cfg(test)] @@ -125,6 +142,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 96757998ff..e623b0257f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -93,6 +93,7 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -156,10 +157,17 @@ pub(crate) struct ThreadSessionState { pub(crate) service_tier: Option, pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - /// Canonical active permissions for this session. Legacy app-server - /// responses are converted to a profile at ingestion time using the - /// response cwd so cached sessions do not reinterpret cwd-bound grants. + /// Canonical active permissions for this session. + /// + /// Embedded app-server responses intentionally do not echo the + /// experimental profile payload, so the TUI keeps its local high-fidelity + /// profile. Remote responses are converted from the legacy sandbox field + /// using the response cwd so cached sessions do not reinterpret cwd-bound + /// grants. pub(crate) permission_profile: PermissionProfile, + /// Named profile that produced `permission_profile`, when the server knows + /// the permissions came from `default_permissions`. + pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, @@ -365,7 +373,7 @@ impl AppServerSession { }) .await .wrap_err("thread/start failed during TUI bootstrap")?; - started_thread_from_start_response(response, config).await + started_thread_from_start_response(response, config, self.thread_params_mode()).await } pub(crate) async fn resume_thread( @@ -390,7 +398,9 @@ impl AppServerSession { let fork_parent_title = self .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) .await; - let mut started = started_thread_from_resume_response(response, &config).await?; + let mut started = + started_thread_from_resume_response(response, &config, self.thread_params_mode()) + .await?; started.session.fork_parent_title = fork_parent_title; Ok(started) } @@ -417,7 +427,8 @@ impl AppServerSession { let fork_parent_title = self .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) .await; - let mut started = started_thread_from_fork_response(response, &config).await?; + let mut started = + started_thread_from_fork_response(response, &config, self.thread_params_mode()).await?; started.session.fork_parent_title = fork_parent_title; Ok(started) } @@ -1251,10 +1262,12 @@ fn thread_cwd_from_config( async fn started_thread_from_start_response( response: ThreadStartResponse, config: &Config, + thread_params_mode: ThreadParamsMode, ) -> Result { - let session = thread_session_state_from_thread_start_response(&response, config) - .await - .map_err(color_eyre::eyre::Report::msg)?; + let session = + thread_session_state_from_thread_start_response(&response, config, thread_params_mode) + .await + .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { session, turns: response.thread.turns, @@ -1264,10 +1277,12 @@ async fn started_thread_from_start_response( async fn started_thread_from_resume_response( response: ThreadResumeResponse, config: &Config, + thread_params_mode: ThreadParamsMode, ) -> Result { - let session = thread_session_state_from_thread_resume_response(&response, config) - .await - .map_err(color_eyre::eyre::Report::msg)?; + let session = + thread_session_state_from_thread_resume_response(&response, config, thread_params_mode) + .await + .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { session, turns: response.thread.turns, @@ -1277,10 +1292,12 @@ async fn started_thread_from_resume_response( async fn started_thread_from_fork_response( response: ThreadForkResponse, config: &Config, + thread_params_mode: ThreadParamsMode, ) -> Result { - let session = thread_session_state_from_thread_fork_response(&response, config) - .await - .map_err(color_eyre::eyre::Report::msg)?; + let session = + thread_session_state_from_thread_fork_response(&response, config, thread_params_mode) + .await + .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { session, turns: response.thread.turns, @@ -1290,7 +1307,14 @@ async fn started_thread_from_fork_response( async fn thread_session_state_from_thread_start_response( response: &ThreadStartResponse, config: &Config, + thread_params_mode: ThreadParamsMode, ) -> Result { + let permission_profile = permission_profile_from_thread_response( + &response.sandbox, + response.cwd.as_path(), + config, + thread_params_mode, + ); thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), @@ -1301,16 +1325,8 @@ async fn thread_session_state_from_thread_start_response( response.service_tier, response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response - .permission_profile - .clone() - .map(Into::into) - .unwrap_or_else(|| { - PermissionProfile::from_legacy_sandbox_policy_for_cwd( - &response.sandbox.to_core(), - response.cwd.as_path(), - ) - }), + permission_profile, + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1322,7 +1338,14 @@ async fn thread_session_state_from_thread_start_response( async fn thread_session_state_from_thread_resume_response( response: &ThreadResumeResponse, config: &Config, + thread_params_mode: ThreadParamsMode, ) -> Result { + let permission_profile = permission_profile_from_thread_response( + &response.sandbox, + response.cwd.as_path(), + config, + thread_params_mode, + ); thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), @@ -1333,16 +1356,8 @@ async fn thread_session_state_from_thread_resume_response( response.service_tier, response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response - .permission_profile - .clone() - .map(Into::into) - .unwrap_or_else(|| { - PermissionProfile::from_legacy_sandbox_policy_for_cwd( - &response.sandbox.to_core(), - response.cwd.as_path(), - ) - }), + permission_profile, + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1354,7 +1369,14 @@ async fn thread_session_state_from_thread_resume_response( async fn thread_session_state_from_thread_fork_response( response: &ThreadForkResponse, config: &Config, + thread_params_mode: ThreadParamsMode, ) -> Result { + let permission_profile = permission_profile_from_thread_response( + &response.sandbox, + response.cwd.as_path(), + config, + thread_params_mode, + ); thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), @@ -1365,16 +1387,8 @@ async fn thread_session_state_from_thread_fork_response( response.service_tier, response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response - .permission_profile - .clone() - .map(Into::into) - .unwrap_or_else(|| { - PermissionProfile::from_legacy_sandbox_policy_for_cwd( - &response.sandbox.to_core(), - response.cwd.as_path(), - ) - }), + permission_profile, + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1383,6 +1397,20 @@ async fn thread_session_state_from_thread_fork_response( .await } +fn permission_profile_from_thread_response( + sandbox: &codex_app_server_protocol::SandboxPolicy, + cwd: &std::path::Path, + config: &Config, + thread_params_mode: ThreadParamsMode, +) -> PermissionProfile { + match thread_params_mode { + ThreadParamsMode::Embedded => config.permissions.permission_profile(), + ThreadParamsMode::Remote => { + PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd) + } + } +} + fn review_target_to_app_server( target: CoreReviewTarget, ) -> codex_app_server_protocol::ReviewTarget { @@ -1417,6 +1445,7 @@ async fn thread_session_state_from_thread_response( approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, + active_permission_profile: Option, cwd: AbsolutePathBuf, instruction_source_paths: Vec, reasoning_effort: Option, @@ -1442,6 +1471,7 @@ async fn thread_session_state_from_thread_response( approval_policy, approvals_reviewer, permission_profile, + active_permission_profile, cwd, instruction_source_paths, reasoning_effort, @@ -1791,13 +1821,17 @@ mod tests { .to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path()) .expect("read-only profile must be legacy-compatible") .into(), - permission_profile: Some(read_only_profile.into()), + active_permission_profile: None, reasoning_effort: None, }; - let started = started_thread_from_resume_response(response.clone(), &config) - .await - .expect("resume response should map"); + let started = started_thread_from_resume_response( + response.clone(), + &config, + ThreadParamsMode::Remote, + ) + .await + .expect("resume response should map"); assert_eq!(started.session.forked_from_id, Some(forked_from_id)); assert_eq!( started.session.instruction_source_paths, @@ -1805,11 +1839,10 @@ mod tests { ); assert_eq!( started.session.permission_profile, - response - .permission_profile - .clone() - .map(Into::into) - .expect("response includes profile") + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &response.sandbox.to_core(), + response.cwd.as_path(), + ) ); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); @@ -1839,6 +1872,7 @@ mod tests { AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, PermissionProfile::read_only(), + /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, @@ -1869,6 +1903,7 @@ mod tests { AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, PermissionProfile::read_only(), + /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e7a58d448a..237953d5a1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1651,6 +1651,7 @@ fn thread_session_state_to_legacy_event( approval_policy: session.approval_policy, approvals_reviewer: session.approvals_reviewer, permission_profile: session.permission_profile, + active_permission_profile: session.active_permission_profile, cwd: session.cwd, reasoning_effort: session.reasoning_effort, history_log_id: session.history_log_id, @@ -2383,11 +2384,16 @@ impl ChatWidget { let permission_sync = self .config .permissions - .set_permission_profile(event.permission_profile.clone()); + .set_permission_profile_with_active_profile( + event.permission_profile.clone(), + event.active_permission_profile.clone(), + ); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); self.config.permissions.permission_profile = Constrained::allow_only(event.permission_profile.clone()); + self.config.permissions.active_permission_profile = + event.active_permission_profile.clone(); } self.config.approvals_reviewer = event.approvals_reviewer; self.status_line_project_root_name_cache = None; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a99bc15183..e2ac788da2 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -17,6 +17,7 @@ async fn submission_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -121,6 +122,7 @@ async fn submission_includes_configured_permission_profile() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile.clone(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -171,6 +173,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile.clone(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -214,6 +217,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -309,6 +313,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -374,6 +379,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -414,6 +420,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -454,6 +461,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -497,6 +505,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d3d5de1f12..89f9debe54 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1059,6 +1059,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 86346885b6..5fdb615b97 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -24,6 +24,7 @@ async fn resumed_initial_messages_render_history() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -137,6 +138,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -198,6 +200,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -286,6 +289,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile.clone(), + active_permission_profile: None, cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -344,6 +348,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile, + active_permission_profile: None, cwd: test_path_buf("/home/user/external").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -393,6 +398,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -446,6 +452,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -767,6 +774,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -814,6 +822,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 829fa77f5d..1a0440c55c 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -542,6 +542,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, permission_profile: PermissionProfile::workspace_write(), + active_permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -596,6 +597,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, permission_profile, + active_permission_profile: None, cwd, reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index d369eb710d..143afab52c 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1196,6 +1196,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -1441,6 +1442,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c71c57dfef..ee8f3d63bf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1791,6 +1791,7 @@ async fn session_configured_clears_goal_status_footer() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 9d009a93fb..803cfcde5f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3194,6 +3194,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b9366df208..f71e3e519c 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -9,6 +9,7 @@ use chrono::Local; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -281,18 +282,15 @@ impl StatusHistoryCell { .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); let permission_profile = config.permissions.permission_profile(); + let active_permission_profile = config.permissions.active_permission_profile(); let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path()); - let permissions = if config.permissions.approval_policy.value() == AskForApproval::OnRequest - && permission_profile == PermissionProfile::workspace_write() - { - "Default".to_string() - } else if config.permissions.approval_policy.value() == AskForApproval::Never - && permission_profile == PermissionProfile::Disabled - { - "Full Access".to_string() - } else { - format!("Custom ({sandbox}, {approval})") - }; + let permissions = status_permissions_label( + active_permission_profile.as_ref(), + &permission_profile, + config.permissions.approval_policy.value(), + &sandbox, + &approval, + ); let model_provider = format_model_provider(config); let account = compose_account_display(account_display); let session_id = session_id.as_ref().map(std::string::ToString::to_string); @@ -549,6 +547,44 @@ fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) summary } +fn status_permissions_label( + active_permission_profile: Option<&ActivePermissionProfile>, + permission_profile: &PermissionProfile, + approval_policy: AskForApproval, + sandbox: &str, + approval: &str, +) -> String { + if active_permission_profile.is_some_and(|active| active.id == ":workspace") + && approval_policy == AskForApproval::OnRequest + && sandbox == "workspace-write" + { + return "Default".to_string(); + } + if active_permission_profile.is_some_and(|active| active.id == ":danger-no-sandbox") + && approval_policy == AskForApproval::Never + && permission_profile == &PermissionProfile::Disabled + { + return "Full Access".to_string(); + } + if let Some(active) = active_permission_profile + && active.id != ":workspace" + && active.id != ":danger-no-sandbox" + { + return format!("Profile {} ({sandbox}, {approval})", active.id); + } + if approval_policy == AskForApproval::OnRequest + && permission_profile == &PermissionProfile::workspace_write() + { + return "Default".to_string(); + } + if approval_policy == AskForApproval::Never + && permission_profile == &PermissionProfile::Disabled + { + return "Full Access".to_string(); + } + format!("Custom ({sandbox}, {approval})") +} + impl HistoryCell for StatusHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b6b38cad10..97515f53c4 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -13,6 +13,7 @@ use chrono::TimeZone; use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; @@ -239,6 +240,72 @@ async fn status_permissions_non_default_workspace_write_is_custom() { ); } +#[tokio::test] +async fn status_permissions_named_workspace_profile_is_default() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::workspace_write(), + Some(ActivePermissionProfile::new(":workspace")), + ) + .expect("set permission profile"); + + assert_eq!(permissions_text_for(&config).as_deref(), Some("Default")); +} + +#[tokio::test] +async fn status_permissions_broadened_workspace_profile_shows_name() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + Some(ActivePermissionProfile::new(":workspace")), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Custom (workspace-write with network access, on-request)") + ); +} + +#[tokio::test] +async fn status_permissions_user_defined_profile_shows_name() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::read_only(), + Some(ActivePermissionProfile::new("locked")), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Profile locked (read-only, on-request)") + ); +} + #[tokio::test] async fn status_permissions_full_disk_managed_with_network_is_danger_full_access() { let temp_home = TempDir::new().expect("temp home");