app-server: stop returning thread permission profiles (#22792)

## Why

The app-server thread lifecycle API should no longer expose the full
`PermissionProfile` value. After the permissions-profile migration,
clients should round-trip only the active profile identity through
`activePermissionProfile` and `permissions` when that identity is known.

The full profile is server-side config. Treating a response-derived
legacy sandbox projection as a new local profile can lose named-profile
restrictions and accidentally widen permissions on the next turn. The
legacy `sandbox` response field remains only as the
compatibility/display fallback.

## What Changed

- Removed `permissionProfile` from `ThreadStartResponse`,
`ThreadResumeResponse`, and `ThreadForkResponse`.
- Stopped populating that field in app-server thread start/resume/fork
responses.
- Updated embedded exec/TUI response mapping to derive display
permission state from local config or the legacy sandbox fallback
instead of a response profile value.
- Added a TUI turn override shape that distinguishes preserving server
permissions, selecting an active profile id, and sending a legacy
sandbox for an explicit local override.
- Preserved remote app-server permissions across turns by sending
`permissions` only when an `activePermissionProfile` id is known, and
otherwise sending no sandbox override unless the user selected a local
override.
- Kept embedded `thread/resume` hydration server-authored when
`activePermissionProfile` is absent, which matches the live-thread
attach path where the server ignores requested overrides.
- Updated the app-server README to remove the obsolete lifecycle
response `permissionProfile` reference. The remaining
`permissionProfile` README references are request-side permission
overrides.
- Regenerated app-server JSON schema and TypeScript fixtures.
- Kept the generated typed response enum exempt from
`large_enum_variant`, matching the existing payload enum exemption after
the lifecycle response variants shrank.

## How To Review

Start with `codex-rs/app-server-protocol/src/protocol/v2/thread.rs` to
confirm the response shape, then check the response construction in
`codex-rs/app-server/src/request_processors`. The generated schema and
TypeScript fixture changes are mechanical follow-through from the
protocol removal.

The TUI behavior is the delicate part: review
`codex-rs/tui/src/app_server_session.rs` for response hydration and
turn-start override projection, then
`codex-rs/tui/src/app/thread_routing.rs` for the decision about whether
the next turn should preserve the server snapshot, send an active
profile id, or send a legacy sandbox for an explicit local override.

## Verification

- `just write-app-server-schema`
- `cargo test -p codex-app-server-protocol
thread_lifecycle_responses_default_missing_optional_fields`
- `cargo test -p codex-exec
session_configured_from_thread_response_uses_permission_profile_from_config`
- `cargo test -p codex-tui --lib thread_response`
- `cargo test -p codex-tui turn_permissions_`
- `cargo test -p codex-tui
resume_response_restores_turns_from_thread_items`
- `cargo test -p codex-analytics
track_response_only_enqueues_analytics_relevant_responses`
- `just fix -p codex-analytics`
- `just fix -p codex-app-server-protocol`
- `just fix -p codex-tui`
- `just argument-comment-lint`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22792).
* #22795
* __->__ #22792
This commit is contained in:
Michael Bolin authored and GitHub committed 2026-05-15 12:45:48 -07:00
1 parent 6afe00efda
commit 83bbb4f326
22 files changed
+249 -1170

No files matched your search

+108 -111
View File
@@ -172,6 +172,16 @@ pub(crate) struct AppServerStartedThread {
pub(crate) turns: Vec<Turn>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TurnPermissionsOverride {
/// Leave the app-server thread's sticky permission profile unchanged.
Preserve,
/// Select a named or built-in profile by id.
ActiveProfile(ActivePermissionProfile),
/// Apply a user-selected legacy/custom permission profile.
LegacySandbox(PermissionProfile),
}
impl AppServerSession {
pub(crate) fn new(client: AppServerClient) -> Self {
Self {
@@ -548,8 +558,7 @@ impl AppServerSession {
cwd: PathBuf,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
permissions_override: TurnPermissionsOverride,
workspace_roots: &[AbsolutePathBuf],
model: String,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
@@ -560,13 +569,8 @@ impl AppServerSession {
output_schema: Option<serde_json::Value>,
) -> Result<TurnStartResponse> {
let request_id = self.next_request_id();
let (sandbox_policy, permissions) = turn_permissions_overrides(
&permission_profile,
active_permission_profile,
cwd.as_path(),
workspace_roots,
self.thread_params_mode(),
);
let (sandbox_policy, permissions) =
turn_permissions_overrides(permissions_override, cwd.as_path());
self.client
.request_typed(ClientRequest::TurnStart {
request_id,
@@ -1185,32 +1189,32 @@ fn permissions_selection_from_active_profile(
}
fn turn_permissions_overrides(
permission_profile: &PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
permissions_override: TurnPermissionsOverride,
cwd: &std::path::Path,
_workspace_roots: &[AbsolutePathBuf],
thread_params_mode: ThreadParamsMode,
) -> (
Option<codex_app_server_protocol::SandboxPolicy>,
Option<PermissionProfileSelectionParams>,
) {
let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) {
active_permission_profile.map(permissions_selection_from_active_profile)
} else {
None
};
let sandbox_policy = (matches!(thread_params_mode, ThreadParamsMode::Remote)
|| permissions.is_none())
.then(|| {
let legacy_profile = legacy_compatible_permission_profile(permission_profile, cwd);
let policy = legacy_profile
.to_legacy_sandbox_policy(cwd)
.unwrap_or_else(|err| {
unreachable!("legacy-compatible permissions must project to legacy policy: {err}")
});
policy.into()
});
(sandbox_policy, permissions)
match permissions_override {
TurnPermissionsOverride::Preserve => (None, None),
TurnPermissionsOverride::ActiveProfile(active_permission_profile) => (
None,
Some(permissions_selection_from_active_profile(
active_permission_profile,
)),
),
TurnPermissionsOverride::LegacySandbox(permission_profile) => {
let legacy_profile = legacy_compatible_permission_profile(&permission_profile, cwd);
let policy = legacy_profile
.to_legacy_sandbox_policy(cwd)
.unwrap_or_else(|err| {
unreachable!(
"legacy-compatible permissions must project to legacy policy: {err}"
)
});
(Some(policy.into()), None)
}
}
}
fn permissions_selection_from_config(
@@ -1413,9 +1417,8 @@ async fn thread_session_state_from_thread_start_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<ThreadSessionState, String> {
let permission_profile = permission_profile_from_thread_response(
let permission_profile = display_permission_profile_from_thread_response(
&response.sandbox,
response.permission_profile.as_ref(),
response.cwd.as_path(),
config,
thread_params_mode,
@@ -1446,13 +1449,21 @@ async fn thread_session_state_from_thread_resume_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<ThreadSessionState, String> {
let permission_profile = permission_profile_from_thread_response(
&response.sandbox,
response.permission_profile.as_ref(),
response.cwd.as_path(),
config,
thread_params_mode,
);
let permission_profile = if matches!(thread_params_mode, ThreadParamsMode::Embedded)
&& response.active_permission_profile.is_none()
{
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
&response.sandbox.to_core(),
response.cwd.as_path(),
)
} else {
display_permission_profile_from_thread_response(
&response.sandbox,
response.cwd.as_path(),
config,
thread_params_mode,
)
};
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.forked_from_id.clone(),
@@ -1479,9 +1490,8 @@ async fn thread_session_state_from_thread_fork_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<ThreadSessionState, String> {
let permission_profile = permission_profile_from_thread_response(
let permission_profile = display_permission_profile_from_thread_response(
&response.sandbox,
response.permission_profile.as_ref(),
response.cwd.as_path(),
config,
thread_params_mode,
@@ -1507,16 +1517,12 @@ async fn thread_session_state_from_thread_fork_response(
.await
}
fn permission_profile_from_thread_response(
fn display_permission_profile_from_thread_response(
sandbox: &codex_app_server_protocol::SandboxPolicy,
permission_profile: Option<&codex_app_server_protocol::PermissionProfile>,
cwd: &std::path::Path,
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> PermissionProfile {
if let Some(permission_profile) = permission_profile {
return permission_profile.clone().into();
}
match thread_params_mode {
ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(),
ThreadParamsMode::Remote => {
@@ -1692,16 +1698,12 @@ mod tests {
let cwd = test_path_buf("/workspace/project").abs();
let active_permission_profile =
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
let workspace_roots = vec![cwd.clone()];
let expected_permissions =
permissions_selection_from_active_profile(active_permission_profile.clone());
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::workspace_write(),
Some(active_permission_profile),
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
cwd.as_path(),
&workspace_roots,
ThreadParamsMode::Embedded,
);
assert_eq!(sandbox_policy, None);
@@ -1711,17 +1713,12 @@ mod tests {
#[test]
fn embedded_turn_permissions_select_profile_id_only() {
let cwd = test_path_buf("/workspace/project").abs();
let extra_root = test_path_buf("/workspace/cache").abs();
let active_permission_profile =
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
let workspace_roots = vec![cwd.clone(), extra_root];
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::workspace_write(),
Some(active_permission_profile),
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
cwd.as_path(),
&workspace_roots,
ThreadParamsMode::Embedded,
);
assert_eq!(sandbox_policy, None);
@@ -1734,15 +1731,23 @@ mod tests {
}
#[test]
fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() {
fn turn_permissions_preserve_thread_permissions_without_override() {
let cwd = test_path_buf("/workspace/project").abs();
let (sandbox_policy, permissions) =
turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path());
assert_eq!(sandbox_policy, None);
assert_eq!(permissions, None);
}
#[test]
fn legacy_turn_permissions_project_to_sandbox_when_explicitly_overridden() {
let cwd = test_path_buf("/workspace/project").abs();
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::read_only(),
/*active_permission_profile*/ None,
TurnPermissionsOverride::LegacySandbox(PermissionProfile::read_only()),
cwd.as_path(),
std::slice::from_ref(&cwd),
ThreadParamsMode::Embedded,
);
assert_eq!(
@@ -1755,26 +1760,19 @@ mod tests {
}
#[test]
fn remote_turn_permissions_use_sandbox_even_with_active_profile() {
fn remote_turn_permissions_preserve_active_profile_selection() {
let cwd = test_path_buf("/workspace/project").abs();
let active_permission_profile = ActivePermissionProfile::new("strict");
let expected_permissions =
permissions_selection_from_active_profile(active_permission_profile.clone());
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::read_only(),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
)),
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
cwd.as_path(),
std::slice::from_ref(&cwd),
ThreadParamsMode::Remote,
);
assert_eq!(
sandbox_policy,
Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
network_access: false
})
);
assert_eq!(permissions, None);
assert_eq!(sandbox_policy, None);
assert_eq!(permissions, Some(expected_permissions));
}
#[tokio::test]
@@ -2109,7 +2107,6 @@ mod tests {
.to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path())
.expect("read-only profile must be legacy-compatible")
.into(),
permission_profile: Some(read_only_profile.clone().into()),
active_permission_profile: None,
reasoning_effort: None,
};
@@ -2134,6 +2131,24 @@ mod tests {
assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]);
let embedded_config = ConfigBuilder::default()
.codex_home(temp_dir.path().join("embedded-codex-home"))
.harness_overrides(ConfigOverrides {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
..ConfigOverrides::default()
})
.build()
.await
.expect("config should build");
let started = started_thread_from_resume_response(
response.clone(),
&embedded_config,
ThreadParamsMode::Embedded,
)
.await
.expect("embedded resume response should map");
assert_eq!(started.session.permission_profile, read_only_profile);
let mut empty_roots_response = response;
empty_roots_response.runtime_workspace_roots = Vec::new();
let started = started_thread_from_resume_response(
@@ -2147,61 +2162,43 @@ mod tests {
}
#[tokio::test]
async fn remote_thread_response_prefers_permission_profile_over_legacy_sandbox() {
async fn remote_thread_response_uses_legacy_sandbox_fallback() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let cwd = test_path_buf("/tmp/project").abs();
let fallback_sandbox = PermissionProfile::read_only()
let sandbox = PermissionProfile::read_only()
.to_legacy_sandbox_policy(cwd.as_path())
.expect("read-only profile must be legacy-compatible")
.into();
let response_profile = AppServerPermissionProfile::Managed {
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots {
subpath: Some(".env".into()),
},
},
access: FileSystemAccessMode::None,
},
],
glob_scan_max_depth: None,
},
network: PermissionProfileNetworkPermissions { enabled: false },
};
let split_profile: PermissionProfile = response_profile.clone().into();
assert_eq!(
permission_profile_from_thread_response(
&fallback_sandbox,
Some(&response_profile),
display_permission_profile_from_thread_response(
&sandbox,
cwd.as_path(),
&config,
ThreadParamsMode::Remote,
),
split_profile
PermissionProfile::read_only()
);
}
#[tokio::test]
async fn embedded_thread_response_prefers_permission_profile_when_present() {
async fn embedded_thread_response_uses_local_config_profile() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.harness_overrides(ConfigOverrides {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
..ConfigOverrides::default()
})
.build()
.await
.expect("config should build");
let cwd = test_path_buf("/tmp/project").abs();
let response_profile = PermissionProfile::read_only().into();
assert_eq!(
permission_profile_from_thread_response(
display_permission_profile_from_thread_response(
&codex_app_server_protocol::SandboxPolicy::DangerFullAccess,
Some(&response_profile),
cwd.as_path(),
&config,
ThreadParamsMode::Embedded,