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
parent e6a7368810
commit 8a5306ff88
58 changed files with 1167 additions and 676 deletions

View File

@@ -24,7 +24,6 @@ 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;
@@ -790,6 +789,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
responsesapi_client_metadata: None,
environments: None,
cwd: Some(default_cwd),
runtime_workspace_roots: None,
approval_policy: Some(default_approval_policy.into()),
approvals_reviewer: None,
sandbox_policy: None,
@@ -961,6 +961,13 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams {
model: config.model.clone(),
model_provider: Some(config.model_provider_id.clone()),
cwd: Some(config.cwd.to_string_lossy().to_string()),
runtime_workspace_roots: Some(
config
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect(),
),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(config),
sandbox: sandbox.flatten(),
@@ -984,6 +991,13 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa
model: config.model.clone(),
model_provider: Some(config.model_provider_id.clone()),
cwd: Some(config.cwd.to_string_lossy().to_string()),
runtime_workspace_roots: Some(
config
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect(),
),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(config),
sandbox: sandbox.flatten(),
@@ -997,30 +1011,13 @@ fn permissions_selection_from_config(config: &Config) -> Option<PermissionProfil
config
.permissions
.active_permission_profile()
.map(|active| {
permissions_selection_from_active_profile(
active,
config.cwd.as_path(),
config.permissions.user_visible_workspace_roots(),
)
})
.map(permissions_selection_from_active_profile)
}
fn permissions_selection_from_active_profile(
active: ActivePermissionProfile,
cwd: &Path,
workspace_roots: &[AbsolutePathBuf],
) -> PermissionProfileSelectionParams {
let modifications = workspace_roots
.iter()
.filter(|root| root.as_path() != cwd)
.cloned()
.map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path })
.collect::<Vec<_>>();
PermissionProfileSelectionParams::Profile {
id: active.id,
modifications: (!modifications.is_empty()).then_some(modifications),
}
PermissionProfileSelectionParams::new(active.id)
}
fn sandbox_mode_from_permission_profile(

View File

@@ -459,24 +459,14 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled()
}
#[test]
fn active_profile_selection_includes_extra_workspace_roots_as_modifications() {
let cwd = test_path_buf("/workspace/project").abs();
let extra_root = test_path_buf("/workspace/cache").abs();
let selection = permissions_selection_from_active_profile(
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE),
cwd.as_path(),
&[cwd.clone(), extra_root.clone()],
);
fn active_profile_selection_uses_profile_id_only() {
let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
));
assert_eq!(
selection,
PermissionProfileSelectionParams::Profile {
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
modifications: Some(vec![
PermissionProfileModificationParams::AdditionalWritableRoot { path: extra_root }
]),
}
PermissionProfileSelectionParams::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
);
}
@@ -583,6 +573,7 @@ fn sample_thread_start_response() -> ThreadStartResponse {
model_provider: "openai".to_string(),
service_tier: None,
cwd: test_path_buf("/tmp").abs(),
runtime_workspace_roots: Vec::new(),
instruction_sources: Vec::new(),
approval_policy: codex_app_server_protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,