permissions: resolve profile identity with constraints (#22683)

## Why

This PR is the invariant-cleanup layer that follows the workspace-roots
base merged in [#22610](https://github.com/openai/codex/pull/22610).

#22610 adds `[permissions.<id>.workspace_roots]` and keeps runtime
workspace roots separate from the raw permission profile, but its
in-memory representation is intentionally transitional: `Permissions`
still carries the selected profile identity next to a constrained
`PermissionProfile`. That makes APIs such as
`set_constrained_permission_profile_with_active_profile()` fragile
because the id and value only mean the right thing when every caller
keeps them in sync.

This PR introduces a single resolved profile state so profile identity,
`extends`, the profile value, and profile-declared workspace roots
travel together. The next PR,
[#22611](https://github.com/openai/codex/pull/22611), builds on this by
changing the app-server turn API to select permission profiles by id
plus runtime workspace roots.

## Stack Context

- #22610, now merged: adds profile-declared `workspace_roots`, runtime
workspace roots, and `:workspace_roots` materialization.
- This PR: replaces the parallel active-profile/profile-value fields
with `PermissionProfileState`.
- #22611: switches app-server turn updates toward profile ids plus
runtime workspace roots.
- #22612: updates TUI/exec summaries to show the effective workspace
roots.

Keeping this separate from #22611 is deliberate: reviewers can validate
the internal state invariant before reviewing the app-server protocol
migration.

## What Changed

- Added `ResolvedPermissionProfile::{Legacy, BuiltIn, Named}` and
`PermissionProfileState`.
- Typed built-in profile ids with `BuiltInPermissionProfileId`.
- Moved selected profile identity and profile-declared workspace roots
into the resolved state.
- Replaced `Permissions` parallel profile fields with one
`permission_profile_state`.
- Removed `set_constrained_permission_profile_with_active_profile()`
from session sync paths.
- Kept trusted session replay/`SessionConfigured` compatibility through
explicit session snapshot helpers.
- Updated session configuration, MCP initialization, app-server, exec,
TUI, and guardian call sites to consume `&PermissionProfile` directly.

## Review Guide

Start with `codex-rs/core/src/config/resolved_permission_profile.rs`; it
is the new invariant boundary. Then review
`codex-rs/core/src/config/mod.rs` to see how config loading records
active profile identity and profile workspace roots. The remaining
call-site changes are mostly mechanical fallout from
`Permissions::permission_profile()` returning `&PermissionProfile`
instead of `&Constrained<PermissionProfile>`.

## Verification

The existing config/session coverage now constructs and asserts through
`PermissionProfileState`. The workspace-root config test also asserts
that profile-declared roots are preserved in the resolved state, which
is the behavior #22611 relies on when runtime roots become mutable
through the app-server API.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22683).
* #22612
* #22611
* __->__ #22683
This commit is contained in:
Michael Bolin authored and GitHub committed 2026-05-14 18:47:44 -07:00
1 parent 06bb508547
commit 3c6d727810
25 files changed
+582 -234

No files matched your search

@@ -164,7 +164,7 @@ impl CommandExecRequestProcessor {
let started_network_proxy = match self.config.permissions.network.as_ref() {
Some(spec) => match spec
.start_proxy(
self.config.permissions.permission_profile().get(),
self.config.permissions.permission_profile(),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
@@ -243,8 +243,7 @@ impl CommandExecRequestProcessor {
);
self.config
.permissions
.permission_profile()
.can_set(&effective_permission_profile)
.can_set_permission_profile(&effective_permission_profile)
.map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?;
effective_permission_profile
} else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) {
@@ -264,8 +263,7 @@ impl CommandExecRequestProcessor {
);
self.config
.permissions
.permission_profile()
.can_set(&permission_profile)
.can_set_permission_profile(&permission_profile)
.map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?;
permission_profile
} else {