From f1b52c5b96e6e87e275cd85099676de479c229da Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 29 Apr 2026 16:32:28 -0700 Subject: [PATCH] permissions: expose active profile metadata --- .../analytics/src/analytics_client_tests.rs | 11 +- codex-rs/analytics/src/client_tests.rs | 3 + .../schema/json/ClientRequest.json | 59 +++ .../codex_app_server_protocol.schemas.json | 118 ++++- .../codex_app_server_protocol.v2.schemas.json | 118 ++++- .../schema/json/v2/ThreadForkParams.json | 296 +------------ .../schema/json/v2/ThreadForkResponse.json | 55 ++- .../schema/json/v2/ThreadResumeParams.json | 306 +------------ .../schema/json/v2/ThreadResumeResponse.json | 55 ++- .../schema/json/v2/ThreadStartParams.json | 296 +------------ .../schema/json/v2/ThreadStartResponse.json | 55 ++- .../schema/json/v2/TurnStartParams.json | 306 +------------ .../typescript/v2/ActivePermissionProfile.ts | 21 + .../v2/ActivePermissionProfileModification.ts | 6 + .../v2/PermissionProfileModificationParams.ts | 6 + .../v2/PermissionProfileSelectionParams.ts | 6 + .../typescript/v2/ThreadForkResponse.ts | 6 +- .../typescript/v2/ThreadResumeResponse.ts | 6 +- .../typescript/v2/ThreadStartResponse.ts | 6 +- .../schema/typescript/v2/index.ts | 4 + .../src/protocol/common.rs | 13 +- .../app-server-protocol/src/protocol/v2.rs | 189 ++++++-- codex-rs/app-server/README.md | 17 +- .../app-server/src/codex_message_processor.rs | 206 ++++++--- .../src/message_processor/tracing_tests.rs | 2 +- .../app-server/tests/suite/v2/skills_list.rs | 2 +- .../app-server/tests/suite/v2/turn_start.rs | 34 +- codex-rs/core/src/codex_thread.rs | 5 + codex-rs/core/src/config/config_tests.rs | 164 +++++++ codex-rs/core/src/config/mod.rs | 128 +++++- codex-rs/core/src/session/handlers.rs | 3 + codex-rs/core/src/session/mod.rs | 2 + codex-rs/core/src/session/session.rs | 20 + codex-rs/core/src/session/tests.rs | 8 + codex-rs/exec/src/lib.rs | 177 +++++--- codex-rs/exec/src/lib_tests.rs | 92 +++- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/protocol/src/models.rs | 53 +++ codex-rs/protocol/src/protocol.rs | 17 + codex-rs/thread-manager-sample/src/main.rs | 1 + 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_routing.rs | 13 +- codex-rs/tui/src/app/thread_session_state.rs | 22 +- codex-rs/tui/src/app_server_session.rs | 408 +++++++++++++----- 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 | 119 ++++- ...ched_limits_hide_credits_without_flag.snap | 34 +- ..._snapshot_includes_credits_and_limits.snap | 34 +- ..._status_snapshot_includes_forked_from.snap | 34 +- ...tatus_snapshot_includes_monthly_limit.snap | 30 +- ...s_snapshot_includes_reasoning_details.snap | 2 +- ...hot_shows_active_user_defined_profile.snap | 21 + ...napshot_shows_auto_review_permissions.snap | 21 + ...snapshot_shows_missing_limits_message.snap | 30 +- ...apshot_shows_refreshing_limits_notice.snap | 32 +- ...s_snapshot_shows_stale_limits_message.snap | 34 +- ...shot_shows_unavailable_limits_message.snap | 30 +- ...efreshing_empty_limits_as_unavailable.snap | 30 +- ...snapshot_truncates_in_narrow_terminal.snap | 2 +- ...s_default_reasoning_when_config_empty.snap | 30 +- codex-rs/tui/src/status/tests.rs | 289 ++++++++++++- 71 files changed, 2478 insertions(+), 1655 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 8fd1ee7dc365..0b5fc3b87b13 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; @@ -158,15 +157,12 @@ fn sample_thread_start_response( approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), + permission_profile: None, + 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(), @@ -215,7 +211,8 @@ fn sample_thread_resume_response_with_source( approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), + permission_profile: None, + active_permission_profile: None, reasoning_effort: None, }) } diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index da18da252901..4b6fb54e958c 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -111,6 +111,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, reasoning_effort: None, }) } @@ -127,6 +128,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, reasoning_effort: None, }) } @@ -143,6 +145,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { 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/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8f44e83830c3..d032cc002ca3 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2024,6 +2024,31 @@ } ] }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -2035,6 +2060,40 @@ ], "type": "object" }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "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 35c9dc86be77..05fe355f6434 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 @@ -5389,6 +5389,59 @@ "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` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/v2/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -11526,6 +11579,31 @@ } ] }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -11537,6 +11615,40 @@ ], "type": "object" }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/v2/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -14718,7 +14830,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -16229,7 +16341,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -16533,7 +16645,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "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 03ac3475e14a..34961fa62efe 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,59 @@ "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` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -8180,6 +8233,31 @@ } ] }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -8191,6 +8269,40 @@ ], "type": "object" }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -12604,7 +12716,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -14115,7 +14227,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -14419,7 +14531,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index eb8f3bdb4f63..ed81297d5f79 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -64,26 +64,19 @@ } ] }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { + "PermissionProfileModificationParams": { "oneOf": [ { + "description": "Additional concrete directory that should be writable.", "properties": { "path": { "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "path" + "additionalWritableRoot" ], - "title": "PathFileSystemPathType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, @@ -91,304 +84,45 @@ "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" - ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "PermissionProfile": { + "PermissionProfileSelectionParams": { "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.", + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", + "id": { "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": { + "modifications": { "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "$ref": "#/definitions/PermissionProfileModificationParams" }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" + "profile" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ + "id", "type" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "SandboxMode": { "enum": [ "read-only", 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 b5d6b139b2c0..653c5f238773 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,59 @@ "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` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -2501,7 +2554,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 425fc52a48a0..94ecf2c26f61 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -138,202 +138,6 @@ } ] }, - "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" - } - ] - }, "FunctionCallOutputBody": { "anyOf": [ { @@ -494,135 +298,65 @@ } ] }, - "PermissionProfile": { + "PermissionProfileModificationParams": { "oneOf": [ { - "description": "Codex owns sandbox construction for this profile.", + "description": "Additional concrete directory that should be writable.", "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" + "path": { + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "external" + "additionalWritableRoot" ], - "title": "ExternalPermissionProfileType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, "required": [ - "network", + "path", "type" ], - "title": "ExternalPermissionProfile", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "PermissionProfileFileSystemPermissions": { + "PermissionProfileSelectionParams": { "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { - "entries": { + "id": { + "type": "string" + }, + "modifications": { "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "$ref": "#/definitions/PermissionProfileModificationParams" }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" + "profile" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ + "id", "type" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", 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 7135a5317439..27cf47f2fc58 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,59 @@ "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` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -2501,7 +2554,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index d5e76f05e3c9..d5f0e9bfcc8c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,26 +90,19 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { + "PermissionProfileModificationParams": { "oneOf": [ { + "description": "Additional concrete directory that should be writable.", "properties": { "path": { "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "path" + "additionalWritableRoot" ], - "title": "PathFileSystemPathType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, @@ -117,304 +110,45 @@ "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", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { + "PermissionProfileSelectionParams": { "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "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" - ], + "id": { "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" - } - ] - }, - "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": { + "modifications": { "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "$ref": "#/definitions/PermissionProfileModificationParams" }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" + "profile" ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ - "entries", + "id", "type" ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", 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 5deca9f6990a..7d93606aa43c 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,59 @@ "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` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -2501,7 +2554,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. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 719a1bedc8b9..da1320a796f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -99,202 +99,6 @@ ], "type": "object" }, - "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" - } - ] - }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -310,135 +114,65 @@ ], "type": "string" }, - "PermissionProfile": { + "PermissionProfileModificationParams": { "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.", + "description": "Additional concrete directory that should be writable.", "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" + "path": { + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "external" + "additionalWritableRoot" ], - "title": "ExternalPermissionProfileType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, "required": [ - "network", + "path", "type" ], - "title": "ExternalPermissionProfile", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "PermissionProfileFileSystemPermissions": { + "PermissionProfileSelectionParams": { "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { - "entries": { + "id": { + "type": "string" + }, + "modifications": { "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "$ref": "#/definitions/PermissionProfileModificationParams" }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" + "profile" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ + "id", "type" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", 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 000000000000..cbc8c6ef0a7f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -0,0 +1,21 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; + +export type ActivePermissionProfile = { +/** + * Identifier from `default_permissions` or the implicit built-in default, + * such as `:workspace` or a user-defined `[permissions.]` profile. + */ +id: string, +/** + * Parent profile identifier once permissions profiles support + * inheritance. This is currently always `null`. + */ +extends: string | null, +/** + * Bounded user-requested modifications applied on top of the named + * profile, if any. + */ +modifications: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts new file mode 100644 index 000000000000..1cbee6868a26 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts new file mode 100644 index 000000000000..c619edcea81f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts new file mode 100644 index 000000000000..a415bd0028ed --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; + +export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | 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 1207002514de..ddcef104e951 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -16,8 +16,8 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** - * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. */ 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 20f91b3e9153..f7627c07aeaf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -16,8 +16,8 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** - * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. */ 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 87e15411a28b..ce28a4a1d70a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -16,8 +16,8 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** - * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. */ 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 38a1aaf33420..2058e8570ca3 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,8 @@ 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 { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; @@ -256,7 +258,9 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; +export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; +export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 94659ee7387c..7abe8bf36306 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2126,12 +2126,8 @@ 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(), - ), + permission_profile: None, + active_permission_profile: None, reasoning_effort: None, }, }; @@ -2174,9 +2170,8 @@ mod tests { "sandbox": { "type": "dangerFullAccess" }, - "permissionProfile": { - "type": "disabled" - }, + "permissionProfile": null, + "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 4669ec6cc9b0..235f81a1c701 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -38,6 +38,8 @@ 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::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -1703,6 +1705,109 @@ 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` or the implicit built-in default, + /// such as `:workspace` 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, + /// Bounded user-requested modifications applied on top of the named + /// profile, if any. + #[serde(default)] + pub modifications: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ActivePermissionProfileModification { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +impl From for ActivePermissionProfileModification { + fn from(value: CoreActivePermissionProfileModification) -> Self { + match value { + CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for CoreActivePermissionProfileModification { + fn from(value: ActivePermissionProfileModification) -> Self { + match value { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(ActivePermissionProfileModification::from) + .collect(), + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(CoreActivePermissionProfileModification::from) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileSelectionParams { + /// Select a named built-in or user-defined profile and optionally apply + /// bounded modifications that Codex knows how to validate. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Profile { + id: String, + #[ts(optional = nullable)] + modifications: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileModificationParams { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3459,11 +3564,12 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Full permissions override for this thread. Cannot be combined with - /// `sandbox`. - #[experimental("thread/start.permissionProfile")] + /// Named profile selection for this thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported turn/thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/start.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3540,14 +3646,20 @@ pub struct ThreadStartResponse { pub approval_policy: AskForApproval, /// 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. + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. #[experimental("thread/start.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/start.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3605,11 +3717,12 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Full permissions override for the resumed thread. Cannot be combined - /// with `sandbox`. - #[experimental("thread/resume.permissionProfile")] + /// Named profile selection for the resumed thread. Cannot be combined + /// with `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/resume.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3646,14 +3759,20 @@ pub struct ThreadResumeResponse { pub approval_policy: AskForApproval, /// 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. + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. #[experimental("thread/resume.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/resume.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3702,11 +3821,12 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Full permissions override for the forked thread. Cannot be combined - /// with `sandbox`. - #[experimental("thread/fork.permissionProfile")] + /// Named profile selection for the forked thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/fork.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3743,14 +3863,20 @@ pub struct ThreadForkResponse { pub approval_policy: AskForApproval, /// 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. + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. #[experimental("thread/fork.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/fork.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -5285,11 +5411,13 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Override the full permissions profile for this turn and subsequent - /// turns. Cannot be combined with `sandboxPolicy`. - #[experimental("turn/start.permissionProfile")] + /// Select a named permissions profile for this turn and subsequent turns. + /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` + /// for supported turn adjustments instead of replacing the full + /// permissions profile. + #[experimental("turn/start.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, @@ -10737,6 +10865,9 @@ mod tests { 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] @@ -10764,7 +10895,7 @@ mod tests { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, - permission_profile: None, + permissions: None, model: None, service_tier: None, effort: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d96b0e514a75..0a0fc922dd85 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,7 +88,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. -- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or `permissionProfile`, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or experimental `permissions` profile selection, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. @@ -142,9 +142,10 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Pass `excludeTurns: true` when the client plans to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -166,7 +167,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer `permissionProfile` for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissionProfile`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -237,8 +238,9 @@ Start a fresh thread when you need a new Codex conversation. "cwd": "/Users/me/project", "approvalPolicy": "never", "sandbox": "workspaceWrite", - // Prefer "permissionProfile" for full permission overrides. Do not send - // both "sandbox" and "permissionProfile". + // Prefer experimental profile selection: + // "permissions": { "type": "profile", "id": ":workspace" } + // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) "sessionStartSource": "startup", // optional: "startup" (default) or "clear" @@ -635,8 +637,9 @@ You can optionally specify config overrides on the new turn. If specified, these "writableRoots": ["/Users/me/project"], "networkAccess": true }, - // Prefer "permissionProfile" for full permission overrides. Do not send - // both "sandboxPolicy" and "permissionProfile". + // Prefer experimental profile selection: + // "permissions": { "type": "profile", "id": ":workspace" } + // Do not send both "sandboxPolicy" and "permissions". "model": "gpt-5.1-codex", "effort": "medium", "summary": "concise", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a8b3beb87108..7b784625665f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -106,7 +106,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfile as ApiPermissionProfile; +use codex_app_server_protocol::PermissionProfileModificationParams; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -2409,7 +2410,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, config, service_name, base_instructions, @@ -2423,11 +2424,11 @@ impl CodexMessageProcessor { environments, persist_extended_history, } = params; - if sandbox.is_some() && permission_profile.is_some() { + if sandbox.is_some() && permissions.is_some() { self.outgoing .send_error( request_id, - invalid_request("`permissionProfile` cannot be combined with `sandbox`"), + invalid_request("`permissions` cannot be combined with `sandbox`"), ) .await; return; @@ -2462,7 +2463,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, base_instructions, developer_instructions, personality, @@ -2521,7 +2522,7 @@ impl CodexMessageProcessor { /*approval_policy*/ None, /*approvals_reviewer*/ None, /*sandbox*/ None, - /*permission_profile*/ None, + /*permissions*/ None, /*base_instructions*/ None, /*developer_instructions*/ None, /*personality*/ None, @@ -2833,8 +2834,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(), @@ -2846,7 +2848,8 @@ impl CodexMessageProcessor { approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; Ok::<_, JSONRPCErrorError>((response, thread_started_notification(thread))) @@ -2892,12 +2895,12 @@ impl CodexMessageProcessor { approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - permission_profile: Option, + permissions: Option, base_instructions: Option, developer_instructions: Option, personality: Option, ) -> ConfigOverrides { - ConfigOverrides { + let mut overrides = ConfigOverrides { model, model_provider, service_tier, @@ -2907,14 +2910,15 @@ impl CodexMessageProcessor { approvals_reviewer: approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core), sandbox_mode: sandbox.map(SandboxMode::to_core), - permission_profile: permission_profile.map(Into::into), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, personality, ..Default::default() - } + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); + overrides } async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) { @@ -4196,11 +4200,11 @@ impl CodexMessageProcessor { return; } - if params.sandbox.is_some() && params.permission_profile.is_some() { + if params.sandbox.is_some() && params.permissions.is_some() { self.outgoing .send_error( request_id, - invalid_request("`permissionProfile` cannot be combined with `sandbox`"), + invalid_request("`permissions` cannot be combined with `sandbox`"), ) .await; return; @@ -4233,7 +4237,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, config: mut request_overrides, base_instructions, developer_instructions, @@ -4268,7 +4272,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, base_instructions, developer_instructions, personality, @@ -4375,8 +4379,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, @@ -4388,7 +4393,8 @@ impl CodexMessageProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -4788,7 +4794,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, config: cli_overrides, base_instructions, developer_instructions, @@ -4798,9 +4804,9 @@ impl CodexMessageProcessor { } = params; let include_turns = !exclude_turns; let result = async { - if sandbox.is_some() && permission_profile.is_some() { + if sandbox.is_some() && permissions.is_some() { return Err(invalid_request( - "`permissionProfile` cannot be combined with `sandbox`", + "`permissions` cannot be combined with `sandbox`", )); } @@ -4853,7 +4859,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, base_instructions, developer_instructions, /*personality*/ None, @@ -4969,8 +4975,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(), @@ -4982,7 +4989,8 @@ impl CodexMessageProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -6500,7 +6508,7 @@ impl CodexMessageProcessor { || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() - || params.permission_profile.is_some() + || params.permissions.is_some() || params.model.is_some() || params.service_tier.is_some() || params.effort.is_some() @@ -6508,9 +6516,9 @@ impl CodexMessageProcessor { || collaboration_mode.is_some() || params.personality.is_some(); - if params.sandbox_policy.is_some() && params.permission_profile.is_some() { + if params.sandbox_policy.is_some() && params.permissions.is_some() { return Err(invalid_request( - "`permissionProfile` cannot be combined with `sandboxPolicy`", + "`permissions` cannot be combined with `sandboxPolicy`", )); } @@ -6520,7 +6528,45 @@ impl CodexMessageProcessor { .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core); let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let permission_profile = params.permission_profile.map(Into::into); + let (permission_profile, active_permission_profile) = + if let Some(permissions) = params.permissions { + let snapshot = thread.config_snapshot().await; + let mut overrides = ConfigOverrides { + cwd: cwd.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides( + &mut overrides, + Some(permissions), + ); + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit turn request + // is different: reject it before accepting user input. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid turn context override: {warning}" + ))); + } + ( + Some(config.permissions.permission_profile()), + config.permissions.active_permission_profile(), + ) + } else { + (None, None) + }; let model = params.model; let effort = params.effort.map(Some); let summary = params.summary; @@ -6538,6 +6584,7 @@ impl CodexMessageProcessor { approvals_reviewer, sandbox_policy: sandbox_policy.clone(), permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), windows_sandbox_level: None, model: model.clone(), effort, @@ -6564,6 +6611,7 @@ impl CodexMessageProcessor { approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level: None, model, effort, @@ -8246,13 +8294,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, @@ -8264,7 +8314,8 @@ async fn handle_pending_thread_resume_request( approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(permission_profile.into()), + active_permission_profile, reasoning_effort, }; let token_usage_thread = pending.include_turns.then(|| response.thread.clone()); @@ -8491,15 +8542,11 @@ fn collect_resume_override_mismatches( )); } } - if let Some(requested_permission_profile) = request.permission_profile.as_ref() { - let requested_permission_profile = - codex_protocol::models::PermissionProfile::from(requested_permission_profile.clone()); - if requested_permission_profile != config_snapshot.permission_profile { - mismatch_details.push(format!( - "permission_profile requested={requested_permission_profile:?} active={:?}", - config_snapshot.permission_profile - )); - } + if request.permissions.is_some() { + mismatch_details.push(format!( + "permissions override was provided and ignored while running; active={:?}", + config_snapshot.active_permission_profile + )); } if let Some(requested_personality) = request.personality.as_ref() && config_snapshot.personality.as_ref() != Some(requested_personality) @@ -9388,10 +9435,29 @@ 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 apply_permission_profile_selection_to_config_overrides( + overrides: &mut ConfigOverrides, + permissions: Option, +) { + let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + return; + }; + overrides.default_permissions = Some(id); + overrides + .additional_writable_roots + .extend(modifications.unwrap_or_default().into_iter().map( + |modification| match modification { + PermissionProfileModificationParams::AdditionalWritableRoot { path } => { + path.to_path_buf() + } + }, + )); } fn thread_response_sandbox_policy( @@ -9419,6 +9485,13 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) return true; } + if matches!( + overrides.default_permissions.as_deref(), + Some(":workspace" | ":danger-no-sandbox") + ) { + return true; + } + overrides .permission_profile .as_ref() @@ -9935,23 +10008,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(); @@ -9996,6 +10052,20 @@ mod tests { }, cwd.as_path() )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":workspace".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }, + cwd.as_path() + )); assert!(!requested_permissions_trust_project( &ConfigOverrides { permission_profile: Some(read_only_profile), @@ -10003,6 +10073,13 @@ mod tests { }, cwd.as_path() )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":read-only".to_string()), + ..Default::default() + }, + cwd.as_path() + )); } #[test] @@ -10173,7 +10250,7 @@ mod tests { approval_policy: None, approvals_reviewer: None, sandbox: None, - permission_profile: None, + permissions: None, config: None, base_instructions: None, developer_instructions: None, @@ -10188,6 +10265,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/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index bb247be59e6c..8caf1aaa9652 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -761,7 +761,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { cwd: None, approval_policy: None, sandbox_policy: None, - permission_profile: None, + permissions: None, approvals_reviewer: None, model: None, service_tier: None, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 1105df37ca88..57c1c240ae17 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -666,7 +666,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( approval_policy: None, approvals_reviewer: None, sandbox: None, - permission_profile: None, + permissions: None, config: None, service_name: None, base_instructions: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3ff04d50227a..62fa7e34c24b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -27,9 +27,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangePatchUpdatedNotification; use codex_app_server_protocol::FileChangeRequestApprovalResponse; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; @@ -38,9 +35,7 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; -use codex_app_server_protocol::PermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -67,7 +62,6 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; -use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -675,9 +669,8 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { } #[tokio::test] -async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> { +async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() -> Result<()> { let codex_home = TempDir::new()?; - let disallowed_write_root = TempDir::new()?; create_config_toml( codex_home.path(), "http://localhost/unused", @@ -704,9 +697,6 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> ) .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let disallowed_write_root = AbsolutePathBuf::from_absolute_path(disallowed_write_root.path()) - .expect("tempdir path should be absolute"); - let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, @@ -714,17 +704,9 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> text: "Hello".to_string(), text_elements: Vec::new(), }], - permission_profile: Some(PermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: disallowed_write_root, - }, - access: FileSystemAccessMode::Write, - }], - glob_scan_max_depth: None, - }, + permissions: Some(PermissionProfileSelectionParams::Profile { + id: ":danger-no-sandbox".to_string(), + modifications: None, }), ..Default::default() }) @@ -749,7 +731,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> .await; assert!( turn_started.is_err(), - "did not expect a turn/started notification after rejected permissionProfile" + "did not expect a turn/started notification after rejected permissions selection" ); Ok(()) @@ -1831,7 +1813,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }), - permission_profile: None, + permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1867,7 +1849,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), - permission_profile: None, + permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e1c796ef3225..30c7c4cccbf0 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, @@ -75,6 +77,7 @@ pub struct CodexThreadTurnContextOverrides { pub approvals_reviewer: Option, pub sandbox_policy: Option, pub permission_profile: Option, + pub active_permission_profile: Option, pub windows_sandbox_level: Option, pub model: Option, pub effort: Option>, @@ -225,6 +228,7 @@ impl CodexThread { approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, model, effort, @@ -249,6 +253,7 @@ impl CodexThread { approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, collaboration_mode: Some(collaboration_mode), reasoning_summary: summary, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 097e93baacf4..c03ad74aeff4 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -60,6 +60,8 @@ 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::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; @@ -907,6 +909,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 +938,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 +1278,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 +1297,86 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl Ok(()) } +#[tokio::test] +async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; + let extra_root = extra_root.path().abs(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":read-only".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + additional_writable_roots: vec![extra_root.to_path_buf()], + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), + "expected additional writable root to modify :read-only, policy: {policy:?}" + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some( + ActivePermissionProfile::new(":read-only").with_modifications(vec![ + ActivePermissionProfileModification::AdditionalWritableRoot { path: extra_root }, + ]) + ) + ); + 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 +1402,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()), @@ -1367,6 +1478,12 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); + assert_eq!( + config.permissions.active_permission_profile(), + None, + "implicit :workspace cannot be faithfully re-selected when it includes \ + legacy sandbox_workspace_write settings" + ); match config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, @@ -1484,6 +1601,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 +6019,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 +6214,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 +6363,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 +6497,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(), @@ -7229,6 +7358,41 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( Ok(()) } +#[tokio::test] +async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + ..Default::default() + }; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .harness_overrides(ConfigOverrides { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + + assert_eq!( + config.permissions.permission_profile(), + PermissionProfile::read_only() + ); + assert_eq!(config.permissions.active_permission_profile(), None); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fe9a8e4334f1..9a1302e25743 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -84,6 +84,8 @@ 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::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -103,6 +105,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; use crate::config::permissions::default_builtin_permission_profile_name; @@ -227,6 +230,9 @@ pub struct Permissions { /// Canonical effective runtime permissions after config requirements and /// runtime readable-root additions have been applied. pub permission_profile: Constrained, + /// Named or implicit built-in profile selected by config, 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. @@ -254,6 +260,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() @@ -312,6 +323,7 @@ impl Permissions { ); self.permission_profile.set(permission_profile)?; + self.active_permission_profile = None; Ok(()) } @@ -319,10 +331,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(()) } } @@ -1201,7 +1227,7 @@ fn apply_requirement_constrained_value( configured_value: T, constrained_value: &mut ConstrainedWithSource, startup_warnings: &mut Vec, -) -> std::io::Result<()> +) -> std::io::Result where T: Clone + std::fmt::Debug + Send + Sync, { @@ -1226,9 +1252,10 @@ where ), ) })?; + return Ok(true); } - Ok(()) + Ok(false) } fn mcp_server_matches_requirement( @@ -1628,6 +1655,7 @@ pub struct ConfigOverrides { pub approvals_reviewer: Option, pub sandbox_mode: Option, pub permission_profile: Option, + pub default_permissions: Option, pub model_provider: Option, pub service_tier: Option>, pub config_profile: Option, @@ -1882,6 +1910,7 @@ impl Config { approvals_reviewer: approvals_reviewer_override, sandbox_mode, permission_profile, + default_permissions: default_permissions_override, model_provider, service_tier: service_tier_override, config_profile: config_profile_key, @@ -1906,6 +1935,18 @@ impl Config { "`sandbox_mode` and `permission_profile` overrides cannot both be set", )); } + if sandbox_mode.is_some() && default_permissions_override.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`sandbox_mode` and `default_permissions` overrides cannot both be set", + )); + } + if permission_profile.is_some() && default_permissions_override.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`permission_profile` and `default_permissions` overrides cannot both be set", + )); + } let active_profile_name = config_profile_key .as_ref() @@ -1977,6 +2018,7 @@ impl Config { .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); + let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -1994,13 +2036,16 @@ impl Config { .permissions .as_ref() .is_some_and(|profiles| !profiles.is_empty()); + let default_permissions = default_permissions_override + .as_deref() + .or(cfg.default_permissions.as_deref()); validate_user_permission_profile_names(cfg.permissions.as_ref())?; if has_permission_profiles && !matches!( permission_config_syntax, Some(PermissionConfigSyntax::Legacy) ) - && cfg.default_permissions.is_none() + && default_permissions.is_none() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -2022,15 +2067,19 @@ impl Config { additional_writable_roots.push(memories_root); } - let profiles_are_active = matches!( - permission_config_syntax, - Some(PermissionConfigSyntax::Profiles) - ) || permission_config_syntax.is_none(); - let using_implicit_builtin_profile = permission_config_syntax.is_none(); + let profiles_are_active = default_permissions_override.is_some() + || matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ) + || permission_config_syntax.is_none(); + let using_implicit_builtin_profile = + permission_config_syntax.is_none() && default_permissions.is_none(); let ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + mut 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(); @@ -2041,7 +2090,7 @@ impl Config { // PermissionProfile carries the active network sandbox bit, not the configured // proxy/allowlist policy. Keep that config so active profiles can round-trip // without broadening network behavior. - let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| { + let default_permissions = default_permissions.unwrap_or_else(|| { default_builtin_permission_profile_name( &active_project, windows_sandbox_level, @@ -2076,11 +2125,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(|| { + let default_permissions = default_permissions.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, @@ -2089,12 +2144,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 { @@ -2125,11 +2180,51 @@ impl Config { &file_system_sandbox_policy, network_sandbox_policy, ); + } else if matches!(permission_profile, PermissionProfile::Managed { .. }) + && !requested_additional_writable_roots.is_empty() + { + file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( + resolved_cwd.as_path(), + &requested_additional_writable_roots, + ); + permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ); } + let active_permission_profile = if using_implicit_builtin_profile + && default_permissions == BUILT_IN_WORKSPACE_PROFILE + && cfg.sandbox_workspace_write.is_some() + { + // The implicit built-in profile preserves legacy + // `[sandbox_workspace_write]` customizations, but explicitly + // selecting `:workspace` intentionally ignores those legacy + // settings. Do not advertise a re-selectable active profile + // when doing so would lose roots, network, or tmp settings. + None + } else { + let active_permission_profile = if !requested_additional_writable_roots.is_empty() + && matches!(permission_profile, PermissionProfile::Managed { .. }) + { + ActivePermissionProfile::new(default_permissions).with_modifications( + requested_additional_writable_roots + .iter() + .cloned() + .map(|path| { + ActivePermissionProfileModification::AdditionalWritableRoot { path } + }) + .collect(), + ) + } else { + ActivePermissionProfile::new(default_permissions) + }; + Some(active_permission_profile) + }; ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + active_permission_profile, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2191,6 +2286,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + None, ) }; let approval_policy_was_explicit = approval_policy_override.is_some() @@ -2560,12 +2656,17 @@ impl Config { &mut constrained_approvals_reviewer, &mut startup_warnings, )?; - apply_requirement_constrained_value( + let permission_profile_was_constrained = apply_requirement_constrained_value( "permission_profile", permission_profile, &mut constrained_permission_profile, &mut startup_warnings, )?; + if permission_profile_was_constrained { + // The selected profile no longer describes the effective + // permissions after requirements forced a fallback. + active_permission_profile = None; + } apply_requirement_constrained_value( "web_search_mode", web_search_mode, @@ -2648,6 +2749,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/handlers.rs b/codex-rs/core/src/session/handlers.rs index b35486160590..624331ea62a7 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -161,6 +161,7 @@ pub(super) async fn user_input_or_turn_inner( approvals_reviewer, sandbox_policy: Some(sandbox_policy), permission_profile, + active_permission_profile: None, windows_sandbox_level: None, collaboration_mode, reasoning_summary: summary, @@ -180,6 +181,7 @@ pub(super) async fn user_input_or_turn_inner( approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, model, effort, @@ -211,6 +213,7 @@ pub(super) async fn user_input_or_turn_inner( approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, collaboration_mode, reasoning_summary: summary, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0c5af3fc5eed..65a5366dccfe 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; @@ -603,6 +604,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 03849f7e0738..f4e8c2a989a9 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -60,6 +60,9 @@ pub(crate) struct SessionConfiguration { pub(super) approvals_reviewer: ApprovalsReviewer, /// Canonical permission profile for the session. pub(super) permission_profile: Constrained, + /// Named or implicit built-in 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 +100,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 +134,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(), @@ -206,10 +214,19 @@ impl SessionConfiguration { } if let Some(permission_profile) = updates.permission_profile.clone() { + let active_permission_profile = + updates.active_permission_profile.clone().or_else(|| { + if permission_profile == self.permission_profile() { + self.active_permission_profile.clone() + } else { + None + } + }); next_configuration.set_permission_profile_projection( permission_profile, Some(¤t_file_system_sandbox_policy), )?; + next_configuration.active_permission_profile = active_permission_profile; } 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 +242,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 @@ -285,6 +303,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) approvals_reviewer: Option, pub(crate) sandbox_policy: Option, pub(crate) permission_profile: Option, + pub(crate) active_permission_profile: Option, pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, @@ -887,6 +906,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 e7484e43b7f0..8286c7ce028b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2324,6 +2324,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(), @@ -2426,6 +2427,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(), @@ -2873,6 +2875,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(), @@ -3309,6 +3312,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(), @@ -3413,6 +3417,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(), @@ -3625,6 +3630,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(), @@ -4206,6 +4212,7 @@ fn op_kind_distinguishes_turn_ops() { approvals_reviewer: None, sandbox_policy: None, permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -4839,6 +4846,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 784653620639..f2f0ed030bd4 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -25,6 +25,8 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::PermissionProfileModificationParams; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; @@ -76,13 +78,14 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; 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; @@ -399,6 +402,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result approvals_reviewer: None, sandbox_mode, permission_profile: None, + default_permissions: None, cwd: resolved_cwd, model_provider: model_provider.clone(), service_tier: None, @@ -665,37 +669,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) - .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) + 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) - } + (session_configured.session_id, session_configured) } else { let response: ThreadStartResponse = send_request_with_response( &client, @@ -707,10 +698,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. @@ -745,7 +752,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { items, output_schema, } => { - let permission_profile = Some(config.permissions.permission_profile().into()); let response: TurnStartResponse = send_request_with_response( &client, ClientRequest::TurnStart { @@ -759,7 +765,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, sandbox_policy: None, - permission_profile, + permissions: None, model: None, service_tier: None, effort: default_effort, @@ -916,14 +922,21 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { } fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { + let permissions = permissions_selection_from_config(config); + let sandbox = permissions.is_none().then(|| { + sandbox_mode_from_permission_profile( + &config.permissions.permission_profile(), + config.cwd.as_path(), + ) + }); ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: None, - permission_profile: Some(config.permissions.permission_profile().into()), + sandbox: sandbox.flatten(), + permissions, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), ..ThreadStartParams::default() @@ -931,6 +944,13 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { } fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { + let permissions = permissions_selection_from_config(config); + let sandbox = permissions.is_none().then(|| { + sandbox_mode_from_permission_profile( + &config.permissions.permission_profile(), + config.cwd.as_path(), + ) + }); ThreadResumeParams { thread_id, model: config.model.clone(), @@ -938,13 +958,63 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: None, - permission_profile: Some(config.permissions.permission_profile().into()), + sandbox: sandbox.flatten(), + permissions, config: config_request_overrides_from_config(config), ..ThreadResumeParams::default() } } +fn permissions_selection_from_config(config: &Config) -> Option { + config + .permissions + .active_permission_profile() + .map(permissions_selection_from_active_profile) +} + +fn permissions_selection_from_active_profile( + active: ActivePermissionProfile, +) -> PermissionProfileSelectionParams { + let modifications = active + .modifications + .into_iter() + .map(|modification| match modification { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + PermissionProfileModificationParams::AdditionalWritableRoot { path } + } + }) + .collect::>(); + PermissionProfileSelectionParams::Profile { + id: active.id, + modifications: (!modifications.is_empty()).then_some(modifications), + } +} + +fn sandbox_mode_from_permission_profile( + permission_profile: &PermissionProfile, + cwd: &Path, +) -> Option { + match permission_profile { + PermissionProfile::Disabled => { + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + } + PermissionProfile::External { .. } => None, + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + permission_profile + .network_sandbox_policy() + .is_enabled() + .then_some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + } else if file_system_policy.can_write_path_with_cwd(cwd, cwd) { + Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite) + } else { + Some(codex_app_server_protocol::SandboxMode::ReadOnly) + } + } + } +} + fn config_request_overrides_from_config(config: &Config) -> Option> { config .active_profile @@ -977,6 +1047,7 @@ where fn session_configured_from_thread_start_response( response: &ThreadStartResponse, + config: &Config, ) -> Result { session_configured_from_thread_response( &response.thread.id, @@ -987,8 +1058,12 @@ 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), + response + .permission_profile + .clone() + .map(Into::into) + .unwrap_or_else(|| config.permissions.permission_profile()), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -996,6 +1071,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, @@ -1006,8 +1082,12 @@ 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), + response + .permission_profile + .clone() + .map(Into::into) + .unwrap_or_else(|| config.permissions.permission_profile()), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -1035,8 +1115,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 { @@ -1052,9 +1132,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 bcb17fb87de1..648d51268967 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -363,8 +363,8 @@ async fn thread_start_params_include_review_policy_when_review_policy_is_manual_ ); assert_eq!(params.sandbox, None); assert_eq!( - params.permission_profile, - Some(config.permissions.permission_profile().into()) + params.permissions, + permissions_selection_from_config(&config) ); } @@ -391,9 +391,76 @@ 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() { - let response = ThreadStartResponse { +#[tokio::test] +async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + sandbox_mode: Some(SandboxMode::DangerFullAccess), + ..Default::default() + }) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config with legacy sandbox override"); + + let start_params = thread_start_params_from_config(&config); + let resume_params = thread_resume_params_from_config(&config, "thread-id".to_string()); + + assert_eq!(config.permissions.active_permission_profile(), None); + assert_eq!( + start_params.sandbox, + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + ); + assert_eq!(start_params.permissions, None); + assert_eq!( + resume_params.sandbox, + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + ); + assert_eq!(resume_params.permissions, None); +} + +#[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 = sample_thread_start_response(); + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview); +} + +#[tokio::test] +async fn session_configured_from_thread_response_uses_permission_profile_from_response() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + let mut response = sample_thread_start_response(); + response.permission_profile = Some(PermissionProfile::Disabled.into()); + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!(event.permission_profile, PermissionProfile::Disabled); +} + +fn sample_thread_start_response() -> ThreadStartResponse { + ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), forked_from_id: None, @@ -426,17 +493,8 @@ 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(), - ), + permission_profile: None, + active_permission_profile: None, reasoning_effort: None, - }; - - let event = session_configured_from_thread_start_response(&response) - .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 23dcd6733afa..f6f6d35f2151 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 78ef4cacffb8..eb66ea061996 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 2d7dba7d54f7..198a191d4e9c 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -317,6 +317,59 @@ pub enum PermissionProfile { External { network: NetworkSandboxPolicy }, } +/// Metadata for the named or implicit built-in 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` or the implicit built-in + /// default, 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, + + /// Bounded user-requested modifications applied on top of the named + /// profile, if any. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modifications: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +pub enum ActivePermissionProfileModification { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "snake_case")] + #[ts(rename_all = "snake_case")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +impl ActivePermissionProfile { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + extends: None, + modifications: Vec::new(), + } + } + + pub fn with_modifications( + mut self, + modifications: Vec, + ) -> Self { + self.modifications = modifications; + self + } +} + impl Default for PermissionProfile { fn default() -> Self { Self::Managed { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f8c830184c41..6cdce3404fe1 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; @@ -479,6 +480,12 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] permission_profile: Option, + /// Named or built-in profile that produced `permission_profile`, if + /// the update selected a profile rather than supplying raw + /// permissions. + #[serde(skip_serializing_if = "Option::is_none")] + active_permission_profile: Option, + /// Updated Windows sandbox mode for tool execution. #[serde(skip_serializing_if = "Option::is_none")] windows_sandbox_level: Option, @@ -3508,6 +3515,12 @@ pub struct SessionConfiguredEvent { /// Canonical effective permissions for commands executed in the session. pub permission_profile: PermissionProfile, + /// Named or implicit built-in profile that produced `permission_profile`, + /// when known. + #[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 +3572,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 +3605,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, @@ -5124,6 +5140,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/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index e78823ec3638..0f27a3f2a04c 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -173,6 +173,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), permission_profile: Constrained::allow_any(PermissionProfile::default()), + active_permission_profile: None, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index b40c7fb2b43f..1310a7152b0c 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -646,6 +646,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 eca648251c9b..756c50869dc0 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3571,6 +3571,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, @@ -3810,6 +3811,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, @@ -4322,6 +4324,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, @@ -4385,6 +4388,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, @@ -4478,6 +4482,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, @@ -4864,6 +4869,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, @@ -4976,6 +4982,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 10fc370f580b..2c4e560afaf7 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_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 5957f552e277..d80c3f4fc9d9 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -604,15 +604,24 @@ impl App { } } if should_start_turn { + let config = self.chat_widget.config_ref(); + let approvals_reviewer = + approvals_reviewer.unwrap_or(config.approvals_reviewer); + let active_permission_profile = + if config.permissions.permission_profile() == permission_profile.clone() { + config.permissions.active_permission_profile() + } else { + None + }; app_server .turn_start( thread_id, items.to_vec(), cwd.clone(), approval_policy, - approvals_reviewer - .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), + approvals_reviewer, permission_profile.clone(), + active_permission_profile, model.to_string(), effort, *summary, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 9b6d7e97264c..efe0adf846d4 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 96757998ff74..263838e7b16c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -29,6 +29,8 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionProfileModificationParams; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery; use codex_app_server_protocol::ReviewStartParams; @@ -93,6 +95,8 @@ 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::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -156,10 +160,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. + /// + /// App-server responses may echo the experimental profile payload; when + /// they do, the TUI uses that exact runtime profile. Older/missing + /// responses fall back to the local profile for embedded sessions or to a + /// response-cwd legacy sandbox projection for remote sessions so cached + /// sessions do not reinterpret cwd-bound grants. pub(crate) permission_profile: PermissionProfile, + /// Named or implicit built-in profile that produced `permission_profile`, + /// when the server knows it. + pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, @@ -365,7 +376,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 +401,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 +430,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) } @@ -533,6 +547,7 @@ impl AppServerSession { approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, + active_permission_profile: Option, model: String, effort: Option, summary: Option, @@ -542,26 +557,12 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); - let sandbox_policy = if matches!(self.thread_params_mode(), ThreadParamsMode::Remote) { - let legacy_profile = - legacy_compatible_permission_profile(&permission_profile, cwd.as_path()); - let policy = legacy_profile - .to_legacy_sandbox_policy(cwd.as_path()) - .unwrap_or_else(|err| { - unreachable!( - "legacy-compatible permissions must project to legacy policy: {err}" - ) - }); - Some(policy.into()) - } else { - None - }; - let permission_profile = if matches!(self.thread_params_mode(), ThreadParamsMode::Embedded) - { - Some(permission_profile.into()) - } else { - None - }; + let (sandbox_policy, permissions) = turn_permissions_overrides( + &permission_profile, + active_permission_profile, + cwd.as_path(), + self.thread_params_mode(), + ); self.client .request_typed(ClientRequest::TurnStart { request_id, @@ -574,7 +575,7 @@ impl AppServerSession { approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, - permission_profile, + permissions, model: Some(model), service_tier, effort, @@ -1127,15 +1128,64 @@ fn sandbox_mode_from_permission_profile( } } -fn permission_profile_override_from_config( +fn permissions_selection_from_active_profile( + active: ActivePermissionProfile, +) -> PermissionProfileSelectionParams { + let modifications = active + .modifications + .into_iter() + .map(|modification| match modification { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + PermissionProfileModificationParams::AdditionalWritableRoot { path } + } + }) + .collect::>(); + PermissionProfileSelectionParams::Profile { + id: active.id, + modifications: (!modifications.is_empty()).then_some(modifications), + } +} + +fn turn_permissions_overrides( + permission_profile: &PermissionProfile, + active_permission_profile: Option, + cwd: &std::path::Path, + thread_params_mode: ThreadParamsMode, +) -> ( + Option, + Option, +) { + let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { + active_permission_profile.map(permissions_selection_from_active_profile) + } else { + None + }; + let sandbox_policy = (matches!(thread_params_mode, ThreadParamsMode::Remote) + || permissions.is_none()) + .then(|| { + let legacy_profile = legacy_compatible_permission_profile(permission_profile, cwd); + let policy = legacy_profile + .to_legacy_sandbox_policy(cwd) + .unwrap_or_else(|err| { + unreachable!("legacy-compatible permissions must project to legacy policy: {err}") + }); + policy.into() + }); + (sandbox_policy, permissions) +} + +fn permissions_selection_from_config( config: &Config, thread_params_mode: ThreadParamsMode, -) -> Option { +) -> Option { if matches!(thread_params_mode, ThreadParamsMode::Remote) { return None; } - Some(config.permissions.permission_profile().into()) + config + .permissions + .active_permission_profile() + .map(permissions_selection_from_active_profile) } fn thread_start_params_from_config( @@ -1144,8 +1194,8 @@ fn thread_start_params_from_config( remote_cwd_override: Option<&std::path::Path>, session_start_source: Option, ) -> ThreadStartParams { - let permission_profile = permission_profile_override_from_config(config, thread_params_mode); - let sandbox = permission_profile + let permissions = permissions_selection_from_config(config, thread_params_mode); + let sandbox = permissions .is_none() .then(|| { sandbox_mode_from_permission_profile( @@ -1161,7 +1211,7 @@ fn thread_start_params_from_config( approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, - permission_profile, + permissions, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), session_start_source, @@ -1176,8 +1226,8 @@ fn thread_resume_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadResumeParams { - let permission_profile = permission_profile_override_from_config(&config, thread_params_mode); - let sandbox = permission_profile + let permissions = permissions_selection_from_config(&config, thread_params_mode); + let sandbox = permissions .is_none() .then(|| { sandbox_mode_from_permission_profile( @@ -1194,7 +1244,7 @@ fn thread_resume_params_from_config( approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, - permission_profile, + permissions, config: config_request_overrides_from_config(&config), persist_extended_history: true, ..ThreadResumeParams::default() @@ -1207,8 +1257,8 @@ fn thread_fork_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadForkParams { - let permission_profile = permission_profile_override_from_config(&config, thread_params_mode); - let sandbox = permission_profile + let permissions = permissions_selection_from_config(&config, thread_params_mode); + let sandbox = permissions .is_none() .then(|| { sandbox_mode_from_permission_profile( @@ -1225,7 +1275,7 @@ fn thread_fork_params_from_config( approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, - permission_profile, + permissions, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), developer_instructions: config.developer_instructions.clone(), @@ -1251,10 +1301,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 +1316,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 +1331,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 +1346,15 @@ 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.permission_profile.as_ref(), + 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 +1365,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 +1378,15 @@ 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.permission_profile.as_ref(), + 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 +1397,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 +1410,15 @@ 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.permission_profile.as_ref(), + 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 +1429,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 +1439,24 @@ async fn thread_session_state_from_thread_fork_response( .await } +fn permission_profile_from_thread_response( + sandbox: &codex_app_server_protocol::SandboxPolicy, + permission_profile: Option<&codex_app_server_protocol::PermissionProfile>, + cwd: &std::path::Path, + config: &Config, + thread_params_mode: ThreadParamsMode, +) -> PermissionProfile { + if let Some(permission_profile) = permission_profile { + return permission_profile.clone().into(); + } + 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 +1491,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 +1517,7 @@ async fn thread_session_state_from_thread_response( approval_policy, approvals_reviewer, permission_profile, + active_permission_profile, cwd, instruction_source_paths, reasoning_effort, @@ -1505,6 +1581,7 @@ fn app_server_credits_snapshot_to_core( mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; + use crate::legacy_core::config::ConfigOverrides; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -1530,7 +1607,15 @@ mod tests { #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); - let config = build_config(&temp_dir).await; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + default_permissions: Some(":workspace".to_string()), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("config should build"); let params = thread_start_params_from_config( &config, @@ -1542,8 +1627,11 @@ mod tests { assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); assert_eq!(params.sandbox, None); assert_eq!( - params.permission_profile, - Some(config.permissions.permission_profile().into()) + params.permissions, + config + .permissions + .active_permission_profile() + .map(permissions_selection_from_active_profile) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); } @@ -1563,6 +1651,64 @@ mod tests { assert_eq!(params.session_start_source, Some(ThreadStartSource::Clear)); } + #[test] + fn embedded_turn_permissions_use_active_profile_selection() { + let cwd = test_path_buf("/workspace/project").abs(); + let active_permission_profile = ActivePermissionProfile::new(":workspace"); + let expected_permissions = + permissions_selection_from_active_profile(active_permission_profile.clone()); + + let (sandbox_policy, permissions) = turn_permissions_overrides( + &PermissionProfile::workspace_write(), + Some(active_permission_profile), + cwd.as_path(), + ThreadParamsMode::Embedded, + ); + + assert_eq!(sandbox_policy, None); + assert_eq!(permissions, Some(expected_permissions)); + } + + #[test] + fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() { + let cwd = test_path_buf("/workspace/project").abs(); + + let (sandbox_policy, permissions) = turn_permissions_overrides( + &PermissionProfile::read_only(), + /*active_permission_profile*/ None, + cwd.as_path(), + ThreadParamsMode::Embedded, + ); + + assert_eq!( + sandbox_policy, + Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false + }) + ); + assert_eq!(permissions, None); + } + + #[test] + fn remote_turn_permissions_use_sandbox_even_with_active_profile() { + let cwd = test_path_buf("/workspace/project").abs(); + + let (sandbox_policy, permissions) = turn_permissions_overrides( + &PermissionProfile::read_only(), + Some(ActivePermissionProfile::new(":read-only")), + cwd.as_path(), + ThreadParamsMode::Remote, + ); + + assert_eq!( + sandbox_policy, + Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false + }) + ); + assert_eq!(permissions, None); + } + #[tokio::test] async fn thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -1601,9 +1747,9 @@ mod tests { assert_eq!(start.sandbox, expected_sandbox); assert_eq!(resume.sandbox, expected_sandbox); assert_eq!(fork.sandbox, expected_sandbox); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); + assert_eq!(start.permissions, None); + assert_eq!(resume.permissions, None); + assert_eq!(fork.permissions, None); } #[test] @@ -1704,9 +1850,9 @@ mod tests { assert_eq!(start.sandbox, expected_sandbox); assert_eq!(resume.sandbox, expected_sandbox); assert_eq!(fork.sandbox, expected_sandbox); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); + assert_eq!(start.permissions, None); + assert_eq!(resume.permissions, None); + assert_eq!(fork.permissions, None); } #[tokio::test] @@ -1791,30 +1937,92 @@ 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()), + permission_profile: Some(read_only_profile.clone().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, response.instruction_sources ); - assert_eq!( - started.session.permission_profile, - response - .permission_profile - .clone() - .map(Into::into) - .expect("response includes profile") - ); + assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); } + #[tokio::test] + async fn remote_thread_response_prefers_permission_profile_over_legacy_sandbox() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let cwd = test_path_buf("/tmp/project").abs(); + let fallback_sandbox = PermissionProfile::read_only() + .to_legacy_sandbox_policy(cwd.as_path()) + .expect("read-only profile must be legacy-compatible") + .into(); + let split_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { + subpath: Some(".env".into()), + }, + }, + access: FileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: None, + }, + network: NetworkSandboxPolicy::Restricted, + }; + let response_profile = split_profile.clone().into(); + + assert_eq!( + permission_profile_from_thread_response( + &fallback_sandbox, + Some(&response_profile), + cwd.as_path(), + &config, + ThreadParamsMode::Remote, + ), + split_profile + ); + } + + #[tokio::test] + async fn embedded_thread_response_prefers_permission_profile_when_present() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let cwd = test_path_buf("/tmp/project").abs(); + let response_profile = PermissionProfile::read_only().into(); + + assert_eq!( + permission_profile_from_thread_response( + &codex_app_server_protocol::SandboxPolicy::DangerFullAccess, + Some(&response_profile), + cwd.as_path(), + &config, + ThreadParamsMode::Embedded, + ), + PermissionProfile::read_only() + ); + } + #[tokio::test] async fn session_configured_populates_history_metadata() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -1839,6 +2047,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 +2078,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 5099c7eae447..cdd31e5329bb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1645,6 +1645,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, @@ -2377,11 +2378,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 a99bc1518340..e2ac788da28e 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 b63912a591c7..ec478724860c 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 86346885b6b9..5fdb615b9731 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 30c40fe01c5e..f2d8e2d0c66b 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 d369eb710d73..143afab52c19 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 eff2c409e798..530629b2aed5 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 9d009a93fb35..803cfcde5f0f 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 b9366df208e4..918ddc177a29 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -9,6 +9,9 @@ use chrono::Local; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -281,18 +284,20 @@ 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 approval = status_approval_label( + config.permissions.approval_policy.value(), + config.approvals_reviewer, + &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); @@ -537,11 +542,17 @@ impl StatusHistoryCell { fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) -> String { let summary = summarize_permission_profile(permission_profile, cwd); + if let Some(details) = summary.strip_prefix("read-only") { + if details.contains("(network access enabled)") { + return "read-only with network access".to_string(); + } + return "read-only".to_string(); + } if let Some(details) = summary.strip_prefix("workspace-write") { if details.contains("(network access enabled)") { - return "workspace-write with network access".to_string(); + return "workspace with network access".to_string(); } - return "workspace-write".to_string(); + return "workspace".to_string(); } if summary == "custom permissions (network access enabled)" { return "custom permissions with network access".to_string(); @@ -549,6 +560,88 @@ 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 { + let active_id = active_permission_profile.map(|active| active.id.as_str()); + let writable_root_modifications = active_permission_profile + .map(|active| { + active + .modifications + .iter() + .filter(|modification| { + matches!( + modification, + ActivePermissionProfileModification::AdditionalWritableRoot { .. } + ) + }) + .count() + }) + .unwrap_or(0); + let modification_suffix = match writable_root_modifications { + 0 => String::new(), + 1 => " + 1 writable root".to_string(), + count => format!(" + {count} writable roots"), + }; + match active_id { + Some(":read-only") => { + let label = if sandbox == "read-only with network access" { + "Read Only with network access" + } else { + "Read Only" + }; + return format!("{label}{modification_suffix} ({approval})"); + } + Some(":workspace") => match sandbox { + "workspace" => return format!("Workspace{modification_suffix} ({approval})"), + "workspace with network access" => { + return format!("Workspace with network access{modification_suffix} ({approval})"); + } + _ => {} + }, + Some(":danger-no-sandbox") if permission_profile == &PermissionProfile::Disabled => { + return if approval_policy == AskForApproval::Never { + "Full Access".to_string() + } else { + format!("No Sandbox ({approval})") + }; + } + Some(id) => return format!("Profile {id}{modification_suffix} ({sandbox}, {approval})"), + None => {} + } + + if sandbox == "read-only" { + return format!("Read Only ({approval})"); + } + if approval_policy == AskForApproval::OnRequest && sandbox == "workspace" { + return format!("Workspace ({approval})"); + } + if approval_policy == AskForApproval::Never + && permission_profile == &PermissionProfile::Disabled + { + return "Full Access".to_string(); + } + format!("Custom ({sandbox}, {approval})") +} + +fn status_approval_label( + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + approval: &str, +) -> String { + if approval_policy == AskForApproval::OnRequest + && approvals_reviewer == ApprovalsReviewer::AutoReview + { + "auto-review".to_string() + } else { + approval.to_string() + } +} + 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/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap index ae85660b9eff..6293c0d74257 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.05K total (700 input + 350 output) │ -│ Context window: 100% left (1.45K used / 272K) │ -│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ -│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ -│ Warning: limits may be stale - start new turn to refresh. │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.05K total (700 input + 350 output) │ +│ Context window: 100% left (1.45K used / 272K) │ +│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ +│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap index f01a62611125..0c8dae72cf56 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 2K total (1.4K input + 600 output) │ -│ Context window: 100% left (2.2K used / 272K) │ -│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ -│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ -│ Credits: 38 credits │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 2K total (1.4K input + 600 output) │ +│ Context window: 100% left (2.2K used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ +│ Credits: 38 credits │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap index b473b4cd0c28..f884d006ab96 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ -│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ -│ │ -│ Token usage: 1.2K total (800 input + 400 output) │ -│ Context window: 100% left (1.2K used / 272K) │ -│ Limits: data not available yet │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index e8be9989979d..fc691f6bf76b 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.2K total (800 input + 400 output) │ -│ Context window: 100% left (1.2K used / 272K) │ -│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ +╰────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index 9115a04d5f4c..8719230212f8 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning high, summaries detailed) │ │ Directory: [[workspace]] │ -│ Permissions: Default │ +│ Permissions: Workspace (on-request) │ │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap new file mode 100644 index 000000000000..a57c34bdf183 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Profile locked (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Context window: 100% left (0 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap new file mode 100644 index 000000000000..f1ceb21ab08a --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Workspace (auto-review) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Context window: 100% left (0 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 948ab6ef7b92..79344df58243 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap index ad81d5da7b55..f633cd7f6c03 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap @@ -4,19 +4,19 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ -│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index e07b400e72b6..dc52e22ec461 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ -│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ -│ Warning: limits may be stale - start new turn to refresh. │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap index 99c2d3226841..83d30b5adc4b 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: not available for this account │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap index 99c2d3226841..83d30b5adc4b 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: not available for this account │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 0a342b5f13e8..a048726bb3b1 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning high, summaries de │ │ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on │ +│ Permissions: Custom (workspace with network access, on-reque │ │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap index e0ceb5b68071..5da83e1a4610 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning medium, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning medium, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b6b38cad1049..bbc1c0ead823 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -12,7 +12,10 @@ use chrono::Duration as ChronoDuration; use chrono::TimeZone; use chrono::Utc; use codex_protocol::ThreadId; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; @@ -212,7 +215,7 @@ async fn status_snapshot_includes_reasoning_details() { } #[tokio::test] -async fn status_permissions_non_default_workspace_write_is_custom() { +async fn status_permissions_non_default_workspace_write_uses_workspace_label() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); @@ -235,10 +238,292 @@ async fn status_permissions_non_default_workspace_write_is_custom() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Custom (workspace-write with network access, on-request)") + Some("Custom (workspace with network access, on-request)") ); } +#[tokio::test] +async fn status_permissions_named_read_only_profile_shows_builtin_label() { + 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::read_only(), + Some(ActivePermissionProfile::new(":read-only")), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Read Only (on-request)") + ); +} + +#[tokio::test] +async fn status_permissions_read_only_profile_shows_additional_writable_roots() { + 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"); + let extra_root = test_path_buf("/workspace/extra").abs(); + let file_system_policy = PermissionProfile::read_only() + .file_system_sandbox_policy() + .with_additional_writable_roots(config.cwd.as_path(), std::slice::from_ref(&extra_root)); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + Some( + ActivePermissionProfile::new(":read-only").with_modifications(vec![ + ActivePermissionProfileModification::AdditionalWritableRoot { + path: extra_root, + }, + ]), + ), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Read Only + 1 writable root (on-request)") + ); +} + +#[tokio::test] +async fn status_permissions_named_workspace_profile_shows_builtin_label() { + 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("Workspace (on-request)") + ); +} + +#[tokio::test] +async fn status_permissions_workspace_auto_review_shows_reviewer_label() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + 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("Workspace (auto-review)") + ); +} + +#[tokio::test] +async fn status_permissions_named_profile_shows_additional_writable_roots() { + 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"); + let extra_root = test_path_buf("/workspace/extra").abs(); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::workspace_write_with( + std::slice::from_ref(&extra_root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + Some( + ActivePermissionProfile::new(":workspace").with_modifications(vec![ + ActivePermissionProfileModification::AdditionalWritableRoot { + path: extra_root, + }, + ]), + ), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Workspace + 1 writable root (on-request)") + ); +} + +#[tokio::test] +async fn status_permissions_broadened_workspace_profile_shows_builtin_label() { + 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("Workspace 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_snapshot_shows_active_user_defined_profile() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.cwd = test_path_buf("/workspace/tests").abs(); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::read_only(), + Some(ActivePermissionProfile::new("locked")), + ) + .expect("set permission profile"); + + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + + let composite = new_status_output( + &config, + test_status_account_display().as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_shows_auto_review_permissions() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.cwd = test_path_buf("/workspace/tests").abs(); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::workspace_write(), + Some(ActivePermissionProfile::new(":workspace")), + ) + .expect("set permission profile"); + + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + + let composite = new_status_output( + &config, + test_status_account_display().as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_permissions_full_disk_managed_with_network_is_danger_full_access() { let temp_home = TempDir::new().expect("temp home");