app-server: expose thread permission profiles (#18278)

## Why

The `PermissionProfile` migration needs app-server clients to see the
same constrained permission model that core is using at runtime. Before
this PR, thread lifecycle responses only exposed the legacy
`SandboxPolicy` shape, so clients still had to infer active permissions
from sandbox fields. That makes downstream resume, fork, and override
flows harder to make `PermissionProfile`-first.

External sandbox policies are intentionally excluded from this canonical
view. External enforcement cannot be round-tripped as a
`PermissionProfile`, and exposing a lossy root-write profile would let
clients accidentally change sandbox semantics if they echo the profile
back later.

## What changed

- Adds the app-server v2 `PermissionProfile` wire shape, including
filesystem permissions and glob scan depth metadata.
- Adds `PermissionProfileNetworkPermissions` so the profile response
does not expose active network state through the older
additional-permissions naming.
- Returns `permissionProfile` from thread start, resume, and fork
responses when the active sandbox can be represented as a
`PermissionProfile`.
- Keeps legacy `sandbox` in those responses for compatibility and
documents `permissionProfile` as canonical when present.
- Makes lifecycle `permissionProfile` nullable and returns `null` for
`ExternalSandbox` to avoid exposing a lossy profile.
- Regenerates the app-server JSON schema and TypeScript fixtures.

## Verification

- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server
thread_response_permission_profile_omits_external_sandbox --
--nocapture`
- `cargo check --tests -p codex-analytics -p codex-exec -p codex-tui`
- `just fix -p codex-app-server-protocol -p codex-app-server -p
codex-analytics -p codex-exec -p codex-tui`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/18278).
* #18279
* __->__ #18278
This commit is contained in:
Michael Bolin
2026-04-21 23:52:56 -07:00
committed by GitHub
parent 3a451b6321
commit 5eab9ff8ca
18 changed files with 1416 additions and 15 deletions

View File

@@ -2691,6 +2691,11 @@ impl CodexMessageProcessor {
/*has_in_progress_turn*/ false,
);
let permission_profile = thread_response_permission_profile(
&config_snapshot.sandbox_policy,
config_snapshot.permission_profile,
);
let response = ThreadStartResponse {
thread: thread.clone(),
model: config_snapshot.model,
@@ -2701,6 +2706,7 @@ impl CodexMessageProcessor {
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox: config_snapshot.sandbox_policy.into(),
permission_profile,
reasoning_effort: config_snapshot.reasoning_effort,
};
if listener_task_context.general_analytics_enabled {
@@ -4423,6 +4429,10 @@ impl CodexMessageProcessor {
thread_status,
/*has_live_in_progress_turn*/ false,
);
let permission_profile = thread_response_permission_profile(
&session_configured.sandbox_policy,
codex_thread.config_snapshot().await.permission_profile,
);
let response = ThreadResumeResponse {
thread,
@@ -4434,6 +4444,7 @@ impl CodexMessageProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox: session_configured.sandbox_policy.into(),
permission_profile,
reasoning_effort: session_configured.reasoning_effort,
};
if self.config.features.enabled(Feature::GeneralAnalytics) {
@@ -5068,6 +5079,10 @@ impl CodexMessageProcessor {
.await,
/*has_in_progress_turn*/ false,
);
let permission_profile = thread_response_permission_profile(
&session_configured.sandbox_policy,
forked_thread.config_snapshot().await.permission_profile,
);
let response = ThreadForkResponse {
thread: thread.clone(),
@@ -5079,6 +5094,7 @@ impl CodexMessageProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox: session_configured.sandbox_policy.into(),
permission_profile,
reasoning_effort: session_configured.reasoning_effort,
};
if self.config.features.enabled(Feature::GeneralAnalytics) {
@@ -8456,11 +8472,15 @@ async fn handle_pending_thread_resume_request(
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
cwd,
reasoning_effort,
..
} = pending.config_snapshot;
let instruction_sources = pending.instruction_sources;
let permission_profile =
thread_response_permission_profile(&sandbox_policy, permission_profile);
let response = ThreadResumeResponse {
thread,
model,
@@ -8471,6 +8491,7 @@ async fn handle_pending_thread_resume_request(
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox: sandbox_policy.into(),
permission_profile,
reasoning_effort,
};
let token_usage_thread = response.thread.clone();
@@ -9573,6 +9594,20 @@ fn with_thread_spawn_agent_metadata(
}
}
fn thread_response_permission_profile(
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
permission_profile: codex_protocol::models::PermissionProfile,
) -> Option<codex_app_server_protocol::PermissionProfile> {
match sandbox_policy {
codex_protocol::protocol::SandboxPolicy::DangerFullAccess
| codex_protocol::protocol::SandboxPolicy::ReadOnly { .. }
| codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } => {
Some(permission_profile.into())
}
codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } => None,
}
}
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
@@ -10061,6 +10096,29 @@ mod tests {
);
}
#[test]
fn thread_response_permission_profile_omits_external_sandbox() {
let cwd = test_path_buf("/tmp").abs();
let profile = codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::DangerFullAccess,
cwd.as_path(),
);
assert_eq!(
thread_response_permission_profile(
&SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
},
profile.clone(),
),
None
);
assert_eq!(
thread_response_permission_profile(&SandboxPolicy::DangerFullAccess, profile.clone()),
Some(profile.into())
);
}
#[test]
fn config_load_error_marks_cloud_requirements_failures_for_relogin() {
let err = std::io::Error::other(CloudRequirementsLoadError::new(