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
+143 -20
View File
@@ -36,6 +36,7 @@ use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::FunctionCallOutputBody;
@@ -2153,29 +2154,47 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() ->
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn session_permission_profile_materializes_runtime_workspace_roots() -> anyhow::Result<()> {
async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow::Result<()> {
let codex_home = tempfile::TempDir::new()?;
let cwd = tempfile::TempDir::new()?;
let extra_root = tempfile::TempDir::new()?;
let old_root = test_path_buf("/workspace/old").abs();
let new_root = test_path_buf("/workspace/new").abs();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(crate::config::ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
additional_writable_roots: vec![extra_root.path().to_path_buf()],
additional_writable_roots: vec![old_root.to_path_buf()],
..Default::default()
})
.build()
.await?;
let session_permission_profile_state = session_permission_profile_state_from_config(&config)?;
let file_system_policy = session_permission_profile_state
let stored_file_system_policy = session_permission_profile_state
.permission_profile()
.file_system_sandbox_policy();
assert!(
file_system_policy.can_write_path_with_cwd(extra_root.path(), config.cwd.as_path()),
"session permission profile should carry materialized runtime workspace roots"
!stored_file_system_policy
.can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path()),
"session permission profile state should keep runtime workspace roots symbolic"
);
let mut session_configuration = make_session_configuration_for_tests().await;
session_configuration.cwd = config.cwd.clone();
session_configuration.workspace_roots = config.workspace_roots.clone();
session_configuration.permission_profile_state = session_permission_profile_state;
let initial_policy = session_configuration.file_system_sandbox_policy();
assert!(initial_policy.can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path()));
let updated = session_configuration.apply(&SessionSettingsUpdate {
workspace_roots: Some(vec![new_root.clone()]),
..Default::default()
})?;
let updated_policy = updated.file_system_sandbox_policy();
assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path()));
assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path()));
Ok(())
}
@@ -2917,6 +2936,7 @@ async fn set_rate_limits_retains_previous_credits() {
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: Vec::new(),
@@ -3020,6 +3040,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: Vec::new(),
@@ -3492,6 +3513,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: Vec::new(),
@@ -3561,6 +3583,8 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd
),
)
.expect("set permission profile");
let expected_file_system_sandbox_policy = file_system_sandbox_policy
.materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots);
let updated = session_configuration
.apply(&SessionSettingsUpdate {
@@ -3571,7 +3595,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd
assert_eq!(
updated.file_system_sandbox_policy(),
file_system_sandbox_policy
expected_file_system_sandbox_policy
);
}
@@ -3620,7 +3644,8 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_
})
.expect("permission profile update should succeed");
let mut expected_file_system_policy = requested_file_system_policy;
let mut expected_file_system_policy = requested_file_system_policy
.materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots);
expected_file_system_policy.glob_scan_max_depth = Some(2);
expected_file_system_policy.entries.push(deny_entry);
assert_eq!(
@@ -3675,6 +3700,93 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo
);
}
#[tokio::test]
async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspace_roots() {
let mut session_configuration = make_session_configuration_for_tests().await;
let old_root = tempfile::tempdir().expect("create old root");
let new_root = tempfile::tempdir().expect("create new root");
let profile_root = tempfile::tempdir().expect("create profile root");
let old_root = old_root.path().abs();
let new_root = new_root.path().abs();
let profile_root = profile_root.path().abs();
session_configuration.workspace_roots = vec![old_root.clone()];
let file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let updated = session_configuration
.apply(&SessionSettingsUpdate {
workspace_roots: Some(vec![new_root.clone()]),
permission_profile: Some(permission_profile),
active_permission_profile: Some(ActivePermissionProfile::new("dev")),
profile_workspace_roots: Some(vec![profile_root.clone()]),
..Default::default()
})
.expect("permission profile update should succeed");
let updated_policy = updated.file_system_sandbox_policy();
assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path()));
assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path()));
assert_eq!(
updated.active_permission_profile(),
Some(ActivePermissionProfile::new("dev"))
);
assert_eq!(updated.profile_workspace_roots(), &[profile_root]);
}
#[tokio::test]
async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_update() {
let mut session_configuration = make_session_configuration_for_tests().await;
let old_root = tempfile::tempdir().expect("create old root");
let new_root = tempfile::tempdir().expect("create new root");
let extra_root = tempfile::tempdir().expect("create extra root");
let old_root = old_root.path().abs();
let new_root = new_root.path().abs();
let extra_root = extra_root.path().abs();
session_configuration.cwd = old_root.clone();
session_configuration.workspace_roots = vec![old_root.clone(), extra_root.clone()];
let file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
session_configuration
.set_permission_profile_for_tests(permission_profile)
.expect("set permission profile");
let updated = session_configuration
.apply(&SessionSettingsUpdate {
cwd: Some(new_root.to_path_buf()),
..Default::default()
})
.expect("cwd-only update should succeed");
assert_eq!(
updated.workspace_roots,
vec![new_root.clone(), extra_root.clone()]
);
let updated_policy = updated.file_system_sandbox_policy();
assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path()));
assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd.as_path()));
assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path()));
}
#[cfg_attr(windows, ignore)]
#[tokio::test]
async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
@@ -3762,12 +3874,13 @@ enabled = false
}
#[tokio::test]
async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_update() {
async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_update() {
let mut session_configuration = make_session_configuration_for_tests().await;
let workspace = tempfile::tempdir().expect("create temp dir");
let project_root = workspace.path().join("project");
let original_cwd = project_root.join("subdir");
session_configuration.cwd = original_cwd.abs();
let original_cwd = workspace.path().join("repo-a").abs();
let project_root = workspace.path().join("repo-b").abs();
session_configuration.cwd = original_cwd.clone();
session_configuration.workspace_roots = vec![session_configuration.cwd.clone()];
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
@@ -3790,20 +3903,23 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
let updated = session_configuration
.apply(&SessionSettingsUpdate {
cwd: Some(project_root.clone()),
cwd: Some(project_root.to_path_buf()),
..Default::default()
})
.expect("cwd-only update should succeed");
let expected_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
&updated.sandbox_policy(),
&project_root,
);
assert_eq!(updated.workspace_roots, vec![project_root.clone()]);
assert!(
updated
.file_system_sandbox_policy()
.is_semantically_equivalent_to(&expected_file_system_policy, &project_root),
"cwd-only update should rederive the legacy filesystem policy for the new cwd"
.can_write_path_with_cwd(project_root.as_path(), updated.cwd.as_path()),
"cwd-only update should keep the new cwd writable"
);
assert!(
!updated
.file_system_sandbox_policy()
.can_write_path_with_cwd(original_cwd.as_path(), updated.cwd.as_path()),
"cwd-only update should not keep the old implicit cwd writable"
);
}
@@ -4032,6 +4148,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: Vec::new(),
@@ -4140,6 +4257,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: default_environments,
@@ -4373,6 +4491,7 @@ async fn make_session_with_config_and_rx(
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: default_environments,
@@ -4475,6 +4594,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: default_environments,
@@ -5124,6 +5244,8 @@ fn op_kind_distinguishes_turn_ops() {
final_output_json_schema: None,
responsesapi_client_metadata: None,
cwd: None,
workspace_roots: None,
profile_workspace_roots: None,
approval_policy: None,
approvals_reviewer: None,
sandbox_policy: None,
@@ -5991,6 +6113,7 @@ where
permission_profile_state: config.permissions.permission_profile_state().clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: default_environments,