From 0c9f27ce5391a6384c3ea1e4987c6175691dfbd7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 28 Apr 2026 16:01:53 -0700 Subject: [PATCH] permissions: expose active profile metadata --- .../analytics/src/analytics_client_tests.rs | 2 + .../codex_app_server_protocol.schemas.json | 20 +++++++ .../codex_app_server_protocol.v2.schemas.json | 20 +++++++ .../schema/json/v2/ThreadForkResponse.json | 20 +++++++ .../schema/json/v2/ThreadResumeResponse.json | 20 +++++++ .../schema/json/v2/ThreadStartResponse.json | 20 +++++++ .../typescript/v2/ActivePermissionProfile.ts | 15 ++++++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 2 + .../app-server-protocol/src/protocol/v2.rs | 50 +++++++++++++++++ .../app-server/src/codex_message_processor.rs | 25 ++++++++- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/config/config_tests.rs | 41 ++++++++++++++ codex-rs/core/src/config/mod.rs | 29 ++++++++++ codex-rs/core/src/session/mod.rs | 2 + codex-rs/core/src/session/session.rs | 10 ++++ codex-rs/core/src/session/tests.rs | 7 +++ codex-rs/exec/src/lib.rs | 1 + codex-rs/exec/src/lib_tests.rs | 1 + .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 ++ codex-rs/protocol/src/models.rs | 28 ++++++++++ codex-rs/protocol/src/protocol.rs | 11 ++++ codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/tests.rs | 7 +++ codex-rs/tui/src/app/thread_events.rs | 1 + codex-rs/tui/src/app/thread_session_state.rs | 22 +++++++- codex-rs/tui/src/app_server_session.rs | 12 +++++ 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 | 53 +++++++++++++++---- codex-rs/tui/src/status/tests.rs | 44 +++++++++++++++ 38 files changed, 489 insertions(+), 15 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 352acbe1fc..0f10442c0f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -155,6 +155,7 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) - approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, reasoning_effort: None, }, } @@ -211,6 +212,7 @@ fn sample_thread_resume_response_with_source( approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, reasoning_effort: None, }, } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 2f94b072cd..11f0638555 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 @@ -5345,6 +5345,26 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", 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 88f81ad461..13448e5ec3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -130,6 +130,26 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index b5d6b139b2..2699d77d02 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AgentPath": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 7135a53174..bec0986e5a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AgentPath": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 5deca9f699..31de006a59 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions`, such as a built-in `:workspace` profile or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AgentPath": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts new file mode 100644 index 0000000000..d6e560d0f1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -0,0 +1,15 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ActivePermissionProfile = { +/** + * Identifier from `default_permissions`, such as a built-in `:workspace` + * profile or a user-defined `[permissions.]` profile. + */ +id: string, +/** + * Parent profile identifier once permissions profiles support + * inheritance. This is currently always `null`. + */ +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 3c0178c290..35a87aed62 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,6 +4,7 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; +export type { ActivePermissionProfile } from "./ActivePermissionProfile"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 99f4d7d3e1..ee95d4e081 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2003,6 +2003,7 @@ mod tests { ) .into(), ), + active_permission_profile: None, reasoning_effort: None, }, }; @@ -2048,6 +2049,7 @@ mod tests { "permissionProfile": { "type": "disabled" }, + "activePermissionProfile": null, "reasoningEffort": null } }), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ccefe15a39..bd922f99f4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -38,6 +38,7 @@ use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; use codex_protocol::mcp::Tool as McpTool; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -1658,6 +1659,37 @@ impl From for CorePermissionProfile { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ActivePermissionProfile { + /// Identifier from `default_permissions`, such as a built-in `:workspace` + /// profile or a user-defined `[permissions.]` profile. + pub id: String, + /// Parent profile identifier once permissions profiles support + /// inheritance. This is currently always `null`. + #[serde(default)] + pub extends: Option, +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3470,6 +3502,11 @@ pub struct ThreadStartResponse { #[experimental("thread/start.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named profile that produced `permissionProfile`, when the active + /// permissions came from `default_permissions`. + #[experimental("thread/start.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3576,6 +3613,11 @@ pub struct ThreadResumeResponse { #[experimental("thread/resume.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named profile that produced `permissionProfile`, when the active + /// permissions came from `default_permissions`. + #[experimental("thread/resume.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3673,6 +3715,11 @@ pub struct ThreadForkResponse { #[experimental("thread/fork.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named profile that produced `permissionProfile`, when the active + /// permissions came from `default_permissions`. + #[experimental("thread/fork.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -10659,6 +10706,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] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 362f8692b6..1c6b981b98 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2798,6 +2798,9 @@ impl CodexMessageProcessor { ); 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(), @@ -2810,6 +2813,7 @@ impl CodexMessageProcessor { approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, permission_profile, + active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; Ok::<_, JSONRPCErrorError>((response, thread_started_notification(thread))) @@ -4348,7 +4352,10 @@ impl CodexMessageProcessor { config_snapshot.cwd.as_path(), ); let permission_profile = - thread_response_permission_profile(config_snapshot.permission_profile.clone()); + thread_response_permission_profile(config_snapshot.permission_profile); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); let response = ThreadResumeResponse { thread, @@ -4361,6 +4368,7 @@ impl CodexMessageProcessor { approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, permission_profile, + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; self.analytics_events_client.track_response( @@ -4949,6 +4957,9 @@ impl CodexMessageProcessor { ); 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(), @@ -4961,6 +4972,7 @@ impl CodexMessageProcessor { approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, permission_profile, + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -8223,6 +8235,7 @@ async fn handle_pending_thread_resume_request( approval_policy, approvals_reviewer, permission_profile, + active_permission_profile, cwd, reasoning_effort, .. @@ -8230,6 +8243,8 @@ async fn handle_pending_thread_resume_request( 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, @@ -8242,6 +8257,7 @@ async fn handle_pending_thread_resume_request( approvals_reviewer: approvals_reviewer.into(), sandbox, permission_profile, + active_permission_profile, reasoning_effort, }; let token_usage_thread = pending.include_turns.then(|| response.thread.clone()); @@ -9371,6 +9387,12 @@ fn thread_response_permission_profile( Some(permission_profile.into()) } +fn thread_response_active_permission_profile( + active_permission_profile: Option, +) -> Option { + active_permission_profile.map(Into::into) +} + fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path, @@ -10165,6 +10187,7 @@ mod tests { approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, + active_permission_profile: None, cwd, ephemeral: false, reasoning_effort: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index a2f64e86f4..644b355edd 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -15,6 +15,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseInputItem; @@ -48,6 +49,7 @@ pub struct ThreadConfigSnapshot { pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, + pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, pub ephemeral: bool, pub reasoning_effort: Option, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f075a4b7fc..0a58ab6d44 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -906,6 +906,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(()) } @@ -927,6 +935,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 @@ -1266,6 +1275,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:?}" @@ -1302,6 +1319,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()), @@ -1483,6 +1512,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(()) } @@ -5893,6 +5930,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: None, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6087,6 +6125,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: None, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6235,6 +6274,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: None, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6368,6 +6408,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: None, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6c8258c519..49fb124300 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -82,6 +82,7 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -224,6 +225,9 @@ pub struct Permissions { /// Canonical effective runtime permissions after config requirements and /// runtime readable-root additions have been applied. pub permission_profile: Constrained, + /// Named profile selected by config, when the active permissions came from + /// `default_permissions` rather than an ad-hoc override. + pub active_permission_profile: Option, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -251,6 +255,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() @@ -309,6 +318,7 @@ impl Permissions { ); self.permission_profile.set(permission_profile)?; + self.active_permission_profile = None; Ok(()) } @@ -316,10 +326,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(()) } } @@ -1947,6 +1971,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + active_permission_profile, ) = if let Some(mut permission_profile) = permission_profile { let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); @@ -1992,6 +2017,7 @@ 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(|| { @@ -2046,6 +2072,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + Some(ActivePermissionProfile::new(default_permissions)), ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2107,6 +2134,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + None, ) }; let approval_policy_was_explicit = approval_policy_override.is_some() @@ -2564,6 +2592,7 @@ impl Config { permissions: Permissions { approval_policy: constrained_approval_policy.value, permission_profile: constrained_permission_profile.value, + active_permission_profile, network, allow_login_shell, shell_environment_policy, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d58e065f28..2c6ed17aa5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -90,6 +90,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; @@ -601,6 +602,7 @@ impl Codex { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 03849f7e07..65d29ae7e5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -60,6 +60,8 @@ pub(crate) struct SessionConfiguration { pub(super) approvals_reviewer: ApprovalsReviewer, /// Canonical permission profile for the session. pub(super) permission_profile: Constrained, + /// Named permissions profile selected from config, if any. + pub(super) active_permission_profile: Option, pub(super) windows_sandbox_level: WindowsSandboxLevel, /// Absolute working directory that should be treated as the *root* of the @@ -97,6 +99,10 @@ impl SessionConfiguration { self.permission_profile.get().clone() } + pub(super) fn active_permission_profile(&self) -> Option { + self.active_permission_profile.clone() + } + pub(super) fn sandbox_policy(&self) -> SandboxPolicy { self.permission_profile() .to_legacy_sandbox_policy(&self.cwd) @@ -127,6 +133,7 @@ impl SessionConfiguration { approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), + active_permission_profile: self.active_permission_profile(), cwd: self.cwd.clone(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), @@ -210,6 +217,7 @@ impl SessionConfiguration { permission_profile, Some(¤t_file_system_sandbox_policy), )?; + next_configuration.active_permission_profile = None; } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( @@ -225,6 +233,7 @@ impl SessionConfiguration { network_sandbox_policy, ), )?; + next_configuration.active_permission_profile = None; } else if cwd_changed && file_system_policy_matches_legacy && file_system_policy_has_rebindable_project_root_write @@ -887,6 +896,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), + active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8854744b76..a344021b75 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2293,6 +2293,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(), @@ -2395,6 +2396,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(), @@ -2842,6 +2844,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(), @@ -3278,6 +3281,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(), @@ -3382,6 +3386,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(), @@ -3594,6 +3599,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(), @@ -4776,6 +4782,7 @@ where approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d4044a728d..93e546da34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1050,6 +1050,7 @@ fn session_configured_from_thread_response( permission_profile: permission_profile.unwrap_or_else(|| { PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, cwd.as_path()) }), + active_permission_profile: None, cwd, reasoning_effort, history_log_id: 0, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index bcb17fb87d..d10331f571 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -432,6 +432,7 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() { ) .into(), ), + active_permission_profile: None, reasoning_effort: None, }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 23dcd6733a..f6f6d35f21 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -115,6 +115,7 @@ fn session_configured_produces_thread_started_event() { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 78ef4cacff..eb66ea0619 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -305,6 +305,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -349,6 +350,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -416,6 +418,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 2d7dba7d54..e38f0db834 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -317,6 +317,34 @@ pub enum PermissionProfile { External { network: NetworkSandboxPolicy }, } +/// Metadata for the named permissions profile that produced the active +/// `PermissionProfile`. +/// +/// The runtime must honor `PermissionProfile`; this sidecar exists so clients +/// can display stable profile identity without trying to reverse-engineer a +/// name from the compiled permissions. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)] +pub struct ActivePermissionProfile { + /// Profile identifier from `default_permissions`, such as `:workspace` or a + /// user-defined `[permissions.]` profile. + pub id: String, + + /// Optional parent profile identifier once permissions profiles support + /// inheritance. This is always `None` until that config feature exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub extends: Option, +} + +impl ActivePermissionProfile { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + extends: None, + } + } +} + impl Default for PermissionProfile { fn default() -> Self { Self::Managed { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c9ff684f7c..c239cec3eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ use crate::mcp::ResourceTemplate as McpResourceTemplate; use crate::mcp::Tool as McpTool; use crate::memory_citation::MemoryCitation; use crate::message_history::HistoryEntry; +use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::MessagePhase; @@ -3508,6 +3509,12 @@ pub struct SessionConfiguredEvent { /// Canonical effective permissions for commands executed in the session. pub permission_profile: PermissionProfile, + /// Named profile that produced `permission_profile`, when the active + /// permissions came from `default_permissions`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub active_permission_profile: Option, + /// Working directory that should be treated as the *root* of the /// session. pub cwd: AbsolutePathBuf, @@ -3559,6 +3566,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { // and immediately project it into the canonical `permission_profile`. sandbox_policy: Option, permission_profile: Option, + #[serde(default)] + active_permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, history_log_id: u64, @@ -3590,6 +3599,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { approval_policy: wire.approval_policy, approvals_reviewer: wire.approvals_reviewer, permission_profile, + active_permission_profile: wire.active_permission_profile, cwd: wire.cwd, reasoning_effort: wire.reasoning_effort, history_log_id: wire.history_log_id, @@ -5102,6 +5112,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: permission_profile.clone(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 3b5479f1d5..937e0a9fcc 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -648,6 +648,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: next_cwd.clone().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 29f82ab85d..59b247fa52 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3570,6 +3570,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, @@ -3809,6 +3810,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, @@ -4321,6 +4323,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4384,6 +4387,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4477,6 +4481,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4863,6 +4868,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4975,6 +4981,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 10fc370f58..2c4e560afa 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -350,6 +350,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 9b6d7e9726..efe0adf846 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -3,6 +3,7 @@ use crate::app_server_session::ThreadSessionState; use crate::read_session_model; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; impl App { @@ -18,10 +19,16 @@ impl App { .config_ref() .permissions .permission_profile(); + let active_permission_profile = self + .chat_widget + .config_ref() + .permissions + .active_permission_profile(); let update_session = |session: &mut ThreadSessionState| { session.approval_policy = approval_policy; session.approvals_reviewer = approvals_reviewer; session.permission_profile = permission_profile.clone(); + session.active_permission_profile = active_permission_profile.clone(); }; if self.primary_thread_id == Some(active_thread_id) @@ -43,7 +50,8 @@ impl App { thread_id: ThreadId, thread: &Thread, ) -> ThreadSessionState { - let permission_profile = self.active_permission_profile(); + let permission_profile = self.current_permission_profile(); + let active_permission_profile = self.current_active_permission_profile(); let mut session = self .primary_session_configured .clone() @@ -58,6 +66,7 @@ impl App { approval_policy: self.config.permissions.approval_policy.value(), approvals_reviewer: self.config.approvals_reviewer, permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), @@ -71,6 +80,7 @@ impl App { session.model_provider_id = thread.model_provider.clone(); session.cwd = thread.cwd.clone(); session.permission_profile = permission_profile; + session.active_permission_profile = active_permission_profile; session.instruction_source_paths = Vec::new(); session.rollout_path = thread.path.clone(); if let Some(model) = @@ -85,12 +95,19 @@ impl App { session } - fn active_permission_profile(&self) -> PermissionProfile { + fn current_permission_profile(&self) -> PermissionProfile { self.chat_widget .config_ref() .permissions .permission_profile() } + + fn current_active_permission_profile(&self) -> Option { + self.chat_widget + .config_ref() + .permissions + .active_permission_profile() + } } #[cfg(test)] @@ -125,6 +142,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 96757998ff..cd3e130c38 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -93,6 +93,7 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -160,6 +161,9 @@ pub(crate) struct ThreadSessionState { /// responses are converted to a profile at ingestion time using the /// response cwd so cached sessions do not reinterpret cwd-bound grants. pub(crate) permission_profile: PermissionProfile, + /// Named profile that produced `permission_profile`, when the server knows + /// the permissions came from `default_permissions`. + pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, @@ -1311,6 +1315,7 @@ async fn thread_session_state_from_thread_start_response( response.cwd.as_path(), ) }), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1343,6 +1348,7 @@ async fn thread_session_state_from_thread_resume_response( response.cwd.as_path(), ) }), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1375,6 +1381,7 @@ async fn thread_session_state_from_thread_fork_response( response.cwd.as_path(), ) }), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1417,6 +1424,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 +1450,7 @@ async fn thread_session_state_from_thread_response( approval_policy, approvals_reviewer, permission_profile, + active_permission_profile, cwd, instruction_source_paths, reasoning_effort, @@ -1792,6 +1801,7 @@ mod tests { .expect("read-only profile must be legacy-compatible") .into(), permission_profile: Some(read_only_profile.into()), + active_permission_profile: None, reasoning_effort: None, }; @@ -1839,6 +1849,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 +1880,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 5756bf5d15..1e3106bf50 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1651,6 +1651,7 @@ fn thread_session_state_to_legacy_event( approval_policy: session.approval_policy, approvals_reviewer: session.approvals_reviewer, permission_profile: session.permission_profile, + active_permission_profile: session.active_permission_profile, cwd: session.cwd, reasoning_effort: session.reasoning_effort, history_log_id: session.history_log_id, @@ -2383,11 +2384,16 @@ impl ChatWidget { let permission_sync = self .config .permissions - .set_permission_profile(event.permission_profile.clone()); + .set_permission_profile_with_active_profile( + event.permission_profile.clone(), + event.active_permission_profile.clone(), + ); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); self.config.permissions.permission_profile = Constrained::allow_only(event.permission_profile.clone()); + self.config.permissions.active_permission_profile = + event.active_permission_profile.clone(); } self.config.approvals_reviewer = event.approvals_reviewer; self.status_line_project_root_name_cache = None; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a99bc15183..e2ac788da2 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -17,6 +17,7 @@ async fn submission_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -121,6 +122,7 @@ async fn submission_includes_configured_permission_profile() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile.clone(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -171,6 +173,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile.clone(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -214,6 +217,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -309,6 +313,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -374,6 +379,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -414,6 +420,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -454,6 +461,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -497,6 +505,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d3d5de1f12..89f9debe54 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1059,6 +1059,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 86346885b6..5fdb615b97 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -24,6 +24,7 @@ async fn resumed_initial_messages_render_history() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -137,6 +138,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -198,6 +200,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -286,6 +289,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile.clone(), + active_permission_profile: None, cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -344,6 +348,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile, + active_permission_profile: None, cwd: test_path_buf("/home/user/external").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -393,6 +398,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -446,6 +452,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -767,6 +774,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -814,6 +822,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 829fa77f5d..1a0440c55c 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -542,6 +542,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, permission_profile: PermissionProfile::workspace_write(), + active_permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -596,6 +597,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, permission_profile, + active_permission_profile: None, cwd, reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index d369eb710d..143afab52c 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1196,6 +1196,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -1441,6 +1442,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index e812e69293..3b445c1b9f 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1732,6 +1732,7 @@ async fn session_configured_clears_goal_status_footer() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 9d009a93fb..803cfcde5f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3194,6 +3194,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b9366df208..1671a5512c 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -9,6 +9,7 @@ use chrono::Local; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -281,18 +282,15 @@ impl StatusHistoryCell { .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); let permission_profile = config.permissions.permission_profile(); + let active_permission_profile = config.permissions.active_permission_profile(); let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path()); - let permissions = if config.permissions.approval_policy.value() == AskForApproval::OnRequest - && permission_profile == PermissionProfile::workspace_write() - { - "Default".to_string() - } else if config.permissions.approval_policy.value() == AskForApproval::Never - && permission_profile == PermissionProfile::Disabled - { - "Full Access".to_string() - } else { - format!("Custom ({sandbox}, {approval})") - }; + let permissions = status_permissions_label( + active_permission_profile.as_ref(), + &permission_profile, + config.permissions.approval_policy.value(), + &sandbox, + &approval, + ); let model_provider = format_model_provider(config); let account = compose_account_display(account_display); let session_id = session_id.as_ref().map(std::string::ToString::to_string); @@ -549,6 +547,39 @@ fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) summary } +fn status_permissions_label( + active_permission_profile: Option<&ActivePermissionProfile>, + permission_profile: &PermissionProfile, + approval_policy: AskForApproval, + sandbox: &str, + approval: &str, +) -> String { + if active_permission_profile.is_some_and(|active| active.id == ":workspace") + && approval_policy == AskForApproval::OnRequest + { + return "Default".to_string(); + } + if active_permission_profile.is_some_and(|active| active.id == ":danger-no-sandbox") + && approval_policy == AskForApproval::Never + { + return "Full Access".to_string(); + } + if let Some(active) = active_permission_profile { + return format!("Profile {} ({sandbox}, {approval})", active.id); + } + if approval_policy == AskForApproval::OnRequest + && permission_profile == &PermissionProfile::workspace_write() + { + return "Default".to_string(); + } + if approval_policy == AskForApproval::Never + && permission_profile == &PermissionProfile::Disabled + { + return "Full Access".to_string(); + } + format!("Custom ({sandbox}, {approval})") +} + impl HistoryCell for StatusHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b6b38cad10..324196d81a 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -13,6 +13,7 @@ use chrono::TimeZone; use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; @@ -239,6 +240,49 @@ async fn status_permissions_non_default_workspace_write_is_custom() { ); } +#[tokio::test] +async fn status_permissions_named_workspace_profile_is_default() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::workspace_write_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("Default")); +} + +#[tokio::test] +async fn status_permissions_user_defined_profile_shows_name() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::read_only(), + Some(ActivePermissionProfile::new("locked")), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Profile locked (read-only, on-request)") + ); +} + #[tokio::test] async fn status_permissions_full_disk_managed_with_network_is_danger_full_access() { let temp_home = TempDir::new().expect("temp home");