app-server: use permission ids and runtime workspace roots (#22611)

## Why

This PR builds on [#22610](https://github.com/openai/codex/pull/22610)
and is the app-server side of the migration from mutable per-turn
`SandboxPolicy` replacement toward selecting immutable permission
profiles by id plus mutable runtime workspace roots.

Once permission profiles can carry their own immutable
`workspace_roots`, app-server no longer needs to mutate the selected
`PermissionProfile` just to represent thread-specific filesystem
context. The mutable part now lives on the thread as explicit
`runtimeWorkspaceRoots`, while `:workspace_roots` remains symbolic until
the sandbox is realized for a turn.

## What Changed

- Replaced the v2 permission-selection wrapper surface with plain
profile ids for `thread/start`, `thread/resume`, `thread/fork`, and
`turn/start`.
- Removed the API surface for profile modifications
(`PermissionProfileSelectionParams`,
`PermissionProfileModificationParams`,
`ActivePermissionProfileModification`).
- Added experimental `runtimeWorkspaceRoots` fields to the thread
lifecycle and turn-start APIs.
- Threaded runtime workspace roots through core session/thread
snapshots, turn overrides, app-server request handling, and command
execution permission resolution.
- Kept session permission state symbolic so later runtime root updates
and cwd-only implicit-root retargeting rebind `:workspace_roots`
correctly.
- Updated the embedded clients just enough to send and restore the new
thread state.
- Refreshed the generated schema/TypeScript artifacts and the app-server
README to match the new contract.

## Verification

Targeted coverage for this layer lives in:

- `codex-rs/app-server-protocol/src/protocol/v2/tests.rs`
- `codex-rs/app-server/tests/suite/v2/thread_start.rs`
- `codex-rs/app-server/tests/suite/v2/thread_resume.rs`
- `codex-rs/app-server/tests/suite/v2/turn_start.rs`
- `codex-rs/core/src/session/tests.rs`

The key regression checks exercise that:

- `runtimeWorkspaceRoots` resolve against the effective cwd on thread
start.
- Profile-declared workspace roots are excluded from the runtime
workspace roots returned by app-server.
- A turn-level runtime workspace-root update persists onto the thread
and is returned by `thread/resume`.
- A named permission profile selected on one turn remains symbolic so a
later runtime-root-only turn update changes the actual sandbox writes.
- A cwd-only turn update retargets the implicit runtime cwd root while
preserving additional runtime roots.
- The protocol fixtures and generated client artifacts stay in sync with
the string-based permission selection contract.











---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22611).
* #22612
* __->__ #22611
This commit is contained in:
Michael Bolin
2026-05-14 23:00:05 -07:00
committed by GitHub
Unverified
parent e6a7368810
commit 8a5306ff88
58 changed files with 1167 additions and 676 deletions
+20 -1
View File
@@ -1905,9 +1905,28 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots(
)
.await?;
let cwd_abs = cwd.abs();
let runtime_root_abs = runtime_root.abs();
let profile_root_abs = profile_root.abs();
assert_eq!(
config.workspace_roots,
vec![cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.permissions.workspace_roots(),
&[cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.effective_workspace_roots(),
vec![
cwd_abs.clone(),
runtime_root_abs.clone(),
profile_root_abs.clone()
]
);
let policy = config.permissions.file_system_sandbox_policy();
for root in [cwd.abs(), runtime_root.abs(), profile_root_abs.clone()] {
for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] {
assert!(
policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()),
"expected workspace root to be writable, policy: {policy:?}"
+7 -2
View File
@@ -1231,6 +1231,13 @@ impl Config {
Ok(())
}
pub fn effective_workspace_roots(&self) -> Vec<AbsolutePathBuf> {
let mut workspace_roots = self.workspace_roots.clone();
workspace_roots.extend(self.permissions.profile_workspace_roots().iter().cloned());
dedupe_absolute_paths(&mut workspace_roots);
workspace_roots
}
pub fn to_models_manager_config(&self) -> ModelsManagerConfig {
ModelsManagerConfig {
model_context_window: self.model_context_window,
@@ -2671,8 +2678,6 @@ impl Config {
configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone());
}
dedupe_absolute_paths(&mut configured_workspace_roots);
workspace_roots.extend(configured_workspace_roots.iter().cloned());
dedupe_absolute_paths(&mut workspace_roots);
file_system_sandbox_policy = file_system_sandbox_policy
.with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots);
let mut permission_profile = if let Some(permission_profile) =
@@ -101,24 +101,6 @@ impl ResolvedPermissionProfile {
}
}
fn with_permission_profile(&self, permission_profile: PermissionProfile) -> Self {
match self {
Self::Legacy(_) => Self::legacy(permission_profile),
Self::BuiltIn(profile) => Self::BuiltIn(BuiltInPermissionProfile {
id: profile.id,
extends: profile.extends.clone(),
permission_profile,
profile_workspace_roots: profile.profile_workspace_roots.clone(),
}),
Self::Named(profile) => Self::Named(NamedPermissionProfile {
id: profile.id.clone(),
extends: profile.extends.clone(),
permission_profile,
profile_workspace_roots: profile.profile_workspace_roots.clone(),
}),
}
}
pub(crate) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
match self {
Self::Legacy(_) => None,
@@ -189,19 +171,6 @@ impl PermissionProfileState {
self.resolved_permission_profile.get().permission_profile()
}
pub(crate) fn clone_with_permission_profile(
&self,
permission_profile: PermissionProfile,
) -> ConstraintResult<Self> {
let candidate = self
.resolved_permission_profile
.get()
.with_permission_profile(permission_profile);
let mut state = self.clone();
state.resolved_permission_profile.set(candidate)?;
Ok(state)
}
pub(crate) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
self.resolved_permission_profile
.get()