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
+5 -19
View File
@@ -32,26 +32,12 @@ impl ChatWidget {
self.forked_from = session.forked_from_id;
self.current_rollout_path = session.rollout_path.clone();
self.current_cwd = Some(session.cwd.to_path_buf());
let previous_cwd = self.config.cwd.clone();
let previous_workspace_roots = self.config.workspace_roots.clone();
self.config.cwd = session.cwd.clone();
if !self.config.workspace_roots_explicit {
let mut workspace_roots = vec![session.cwd.clone()];
if previous_workspace_roots
.iter()
.any(|root| root == &previous_cwd)
{
for root in previous_workspace_roots {
if root != previous_cwd
&& !workspace_roots.iter().any(|existing| existing == &root)
{
workspace_roots.push(root);
}
}
}
self.config.workspace_roots = workspace_roots.clone();
self.config.permissions.set_workspace_roots(workspace_roots);
}
let runtime_workspace_roots = session.runtime_workspace_roots.clone();
self.config.workspace_roots = runtime_workspace_roots.clone();
self.config
.permissions
.set_workspace_roots(runtime_workspace_roots);
self.effective_service_tier = session.service_tier.clone();
if let Err(err) = self
.config
@@ -28,6 +28,7 @@ async fn submission_preserves_text_elements_and_local_images() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -131,6 +132,7 @@ async fn submission_includes_configured_permission_profile() {
permission_profile: expected_permission_profile.clone(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -180,6 +182,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
permission_profile: expected_permission_profile.clone(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -221,6 +224,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -314,6 +318,7 @@ async fn enter_with_only_remote_images_submits_user_turn() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -377,6 +382,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -415,6 +421,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -453,6 +460,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -494,6 +502,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -957,6 +957,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -29,6 +29,7 @@ async fn resumed_initial_messages_render_history() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -99,6 +100,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -167,6 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -266,6 +269,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
permission_profile: expected_permission_profile,
active_permission_profile: None,
cwd: expected_cwd.clone(),
runtime_workspace_roots: vec![expected_cwd.clone()],
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -300,7 +304,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
assert_eq!(
chat.config_ref().permissions.effective_permission_profile(),
updated_profile
.materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd)),
.materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd,)),
"effective permissions should still use the current thread runtime workspace roots"
);
}
@@ -319,9 +323,10 @@ async fn session_configured_preserves_profile_workspace_roots() {
.set_workspace_roots(chat.config.workspace_roots.clone());
let session_cwd = test_path_buf("/home/user/sub-agent").abs();
let session_workspace_roots = vec![session_cwd.clone(), profile_root];
let session_runtime_workspace_roots = vec![session_cwd.clone()];
let session_effective_workspace_roots = vec![session_cwd.clone(), profile_root];
let session_permission_profile = PermissionProfile::workspace_write()
.materialize_project_roots_with_workspace_roots(&session_workspace_roots);
.materialize_project_roots_with_workspace_roots(&session_effective_workspace_roots);
let configured = crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
@@ -335,6 +340,7 @@ async fn session_configured_preserves_profile_workspace_roots() {
permission_profile: session_permission_profile.clone(),
active_permission_profile: None,
cwd: session_cwd.clone(),
runtime_workspace_roots: session_runtime_workspace_roots.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -347,7 +353,7 @@ async fn session_configured_preserves_profile_workspace_roots() {
assert_eq!(&chat.config_ref().cwd, &session_cwd);
assert_eq!(
chat.config_ref().permissions.user_visible_workspace_roots(),
session_workspace_roots.as_slice()
session_runtime_workspace_roots.as_slice()
);
assert_eq!(
chat.config_ref().permissions.effective_permission_profile(),
@@ -380,6 +386,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
permission_profile: expected_permission_profile,
active_permission_profile: None,
cwd: test_path_buf("/home/user/external").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -420,6 +427,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -474,6 +482,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -744,6 +753,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_project_path().abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
message_history: None,
@@ -789,6 +799,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_project_path().abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
message_history: None,
@@ -584,6 +584,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure
permission_profile: PermissionProfile::workspace_write(),
active_permission_profile: None,
cwd: test_project_path().abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
message_history: None,
@@ -631,6 +632,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
permission_profile,
active_permission_profile: None,
cwd,
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
message_history: None,
@@ -1217,6 +1217,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -1403,6 +1404,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
@@ -2230,6 +2230,7 @@ async fn session_configured_clears_goal_status_footer() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,