app-server: use profile ids in v2 permission params (#23360)

## Why

The v2 app-server permission profile fields are experimental, but the
previous migration kept a legacy object payload for profile selection.
That made clients aware of server-owned `activePermissionProfile`
metadata such as `extends`, and it kept a
`legacy_additional_writable_roots` path even though
`runtimeWorkspaceRoots` now owns runtime workspace-root selection.

This PR makes the client contract match the intended model: clients
select a permission profile by id, and the server resolves and reports
active profile provenance in response payloads.

Follow-up to #22611.

## What Changed

- Changed `thread/start`, `thread/resume`, `thread/fork`, and
`turn/start` permission profile selection to plain profile id strings.
- Changed `command/exec.permissionProfile` to a plain profile id string
for the same client/server ownership split.
- Removed `PermissionProfileSelectionParams` and the legacy `{ type:
"profile", modifications: [...] }` compatibility deserializer.
- Updated app-server, TUI, and `codex exec` call sites to send only ids,
while keeping `activePermissionProfile` as server response metadata.
- Updated app-server docs and schema fixtures for the revised
`command/exec.permissionProfile` shape.

## Verification

- `cargo test -p codex-app-server-protocol`
- `RUST_MIN_STACK=8388608 cargo test -p codex-app-server`
- `cargo test -p codex-exec`
- `RUST_MIN_STACK=8388608 cargo test -p codex-tui`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23360).
* #23368
* __->__ #23360
This commit is contained in:
Michael Bolin
2026-05-18 17:28:50 -07:00
committed by GitHub
Unverified
parent 5696167fe8
commit 3fd79b7986
21 changed files with 79 additions and 337 deletions
@@ -1555,6 +1555,7 @@ mod tests {
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeOutputModality;
@@ -2994,7 +2995,7 @@ mod tests {
env: None,
size: None,
sandbox_policy: None,
permission_profile: Some(v2::ActivePermissionProfile::read_only()),
permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
},
};
@@ -1,4 +1,3 @@
use super::ActivePermissionProfile;
use super::SandboxPolicy;
use codex_experimental_api_macros::ExperimentalApi;
use schemars::JsonSchema;
@@ -100,13 +99,13 @@ pub struct CommandExecParams {
/// combined with `permissionProfile`.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional active permissions profile for this command.
/// Optional active permissions profile id for this command.
///
/// Defaults to the user's configured permissions when omitted. Cannot be
/// combined with `sandboxPolicy`.
#[experimental("command/exec.permissionProfile")]
#[ts(optional = nullable)]
pub permission_profile: Option<ActivePermissionProfile>,
pub permission_profile: Option<String>,
}
/// Final buffered result for `command/exec`.
@@ -18,9 +18,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use ts_rs::TS;
@@ -333,102 +331,6 @@ impl From<ActivePermissionProfile> for CoreActivePermissionProfile {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionProfileSelectionParams {
id: String,
legacy_additional_writable_roots: Vec<AbsolutePathBuf>,
}
impl PermissionProfileSelectionParams {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
legacy_additional_writable_roots: Vec::new(),
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn into_id(self) -> String {
self.id
}
pub fn legacy_additional_writable_roots(&self) -> &[AbsolutePathBuf] {
&self.legacy_additional_writable_roots
}
}
impl From<String> for PermissionProfileSelectionParams {
fn from(id: String) -> Self {
Self::new(id)
}
}
impl Serialize for PermissionProfileSelectionParams {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.id)
}
}
impl<'de> Deserialize<'de> for PermissionProfileSelectionParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Wire {
Id(String),
LegacyProfile {
#[serde(rename = "type")]
_type: LegacyPermissionProfileSelectionType,
id: String,
#[serde(default)]
modifications: Option<Vec<LegacyPermissionProfileModificationParams>>,
},
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
enum LegacyPermissionProfileSelectionType {
Profile,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum LegacyPermissionProfileModificationParams {
#[serde(rename_all = "camelCase")]
AdditionalWritableRoot { path: AbsolutePathBuf },
}
match Wire::deserialize(deserializer)? {
Wire::Id(id) => Ok(Self::new(id)),
Wire::LegacyProfile {
id, modifications, ..
} => {
let legacy_additional_writable_roots = modifications
.unwrap_or_default()
.into_iter()
.map(|modification| match modification {
LegacyPermissionProfileModificationParams::AdditionalWritableRoot {
path,
} => path,
})
.collect();
Ok(Self {
id,
legacy_additional_writable_roots,
})
}
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -14,6 +14,7 @@ use codex_protocol::mcp::CallToolResult;
use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation;
use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::MessagePhase;
@@ -614,57 +615,49 @@ fn permissions_request_approval_response_accepts_strict_auto_review() {
}
#[test]
fn permission_profile_selection_accepts_legacy_object_shape() {
let additional_root = absolute_path("additional-root");
let params = json!({
"permissions": {
"type": "profile",
"id": ":workspace",
"modifications": [
{
"type": "additionalWritableRoot",
"path": additional_root,
}
],
},
});
let start: ThreadStartParams =
serde_json::from_value(params.clone()).expect("thread/start params deserialize");
assert_legacy_permission_profile_selection(start.permissions, &additional_root);
let resume: ThreadResumeParams = serde_json::from_value(json!({
"threadId": "thread-1",
"permissions": params["permissions"].clone(),
fn permission_profile_selection_uses_id_string() {
let start: ThreadStartParams = serde_json::from_value(json!({
"permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
}))
.expect("thread/resume params deserialize");
assert_legacy_permission_profile_selection(resume.permissions, &additional_root);
let fork: ThreadForkParams = serde_json::from_value(json!({
"threadId": "thread-1",
"permissions": params["permissions"].clone(),
}))
.expect("thread/fork params deserialize");
assert_legacy_permission_profile_selection(fork.permissions, &additional_root);
.expect("thread/start params deserialize");
assert_eq!(
start.permissions,
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
);
let turn: TurnStartParams = serde_json::from_value(json!({
"threadId": "thread-1",
"input": [],
"permissions": params["permissions"].clone(),
"permissions": "dev",
}))
.expect("turn/start params deserialize");
assert_legacy_permission_profile_selection(turn.permissions, &additional_root);
}
assert_eq!(turn.permissions, Some("dev".to_string()));
fn assert_legacy_permission_profile_selection(
selection: Option<PermissionProfileSelectionParams>,
additional_root: &AbsolutePathBuf,
) {
let selection = selection.expect("permissions should be present");
assert_eq!(selection.id(), ":workspace");
let command: CommandExecParams = serde_json::from_value(json!({
"command": ["echo", "hello"],
"permissionProfile": "dev",
}))
.expect("command/exec params deserialize");
assert_eq!(command.permission_profile, Some("dev".to_string()));
let resume: ThreadResumeParams = serde_json::from_value(json!({
"threadId": "thread-1",
"permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
}))
.expect("thread/resume params deserialize");
assert_eq!(
selection.legacy_additional_writable_roots(),
std::slice::from_ref(additional_root)
resume.permissions,
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
);
let fork: ThreadForkParams = serde_json::from_value(json!({
"threadId": "thread-1",
"permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
}))
.expect("thread/fork params deserialize");
assert_eq!(
fork.permissions,
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
);
}
@@ -1,7 +1,6 @@
use super::ActivePermissionProfile;
use super::ApprovalsReviewer;
use super::AskForApproval;
use super::PermissionProfileSelectionParams;
use super::SandboxMode;
use super::SandboxPolicy;
use super::Thread;
@@ -122,10 +121,8 @@ pub struct ThreadStartParams {
pub sandbox: Option<SandboxMode>,
/// Named profile id for this thread. Cannot be combined with `sandbox`.
#[experimental("thread/start.permissions")]
#[schemars(with = "Option<String>")]
#[ts(type = "string | null")]
#[ts(optional = nullable)]
pub permissions: Option<PermissionProfileSelectionParams>,
pub permissions: Option<String>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, JsonValue>>,
#[ts(optional = nullable)]
@@ -284,10 +281,8 @@ pub struct ThreadResumeParams {
/// Named profile id for the resumed thread. Cannot be combined with
/// `sandbox`.
#[experimental("thread/resume.permissions")]
#[schemars(with = "Option<String>")]
#[ts(type = "string | null")]
#[ts(optional = nullable)]
pub permissions: Option<PermissionProfileSelectionParams>,
pub permissions: Option<String>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, serde_json::Value>>,
#[ts(optional = nullable)]
@@ -395,10 +390,8 @@ pub struct ThreadForkParams {
/// Named profile id for the forked thread. Cannot be combined with
/// `sandbox`.
#[experimental("thread/fork.permissions")]
#[schemars(with = "Option<String>")]
#[ts(type = "string | null")]
#[ts(optional = nullable)]
pub permissions: Option<PermissionProfileSelectionParams>,
pub permissions: Option<String>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, serde_json::Value>>,
#[ts(optional = nullable)]
@@ -1,6 +1,5 @@
use super::ApprovalsReviewer;
use super::AskForApproval;
use super::PermissionProfileSelectionParams;
use super::SandboxPolicy;
use super::Turn;
use codex_experimental_api_macros::ExperimentalApi;
@@ -85,10 +84,8 @@ pub struct TurnStartParams {
/// Select a named permissions profile id for this turn and subsequent
/// turns. Cannot be combined with `sandboxPolicy`.
#[experimental("turn/start.permissions")]
#[schemars(with = "Option<String>")]
#[ts(type = "string | null")]
#[ts(optional = nullable)]
pub permissions: Option<PermissionProfileSelectionParams>,
pub permissions: Option<String>,
/// Override the model for this turn and subsequent turns.
#[ts(optional = nullable)]
pub model: Option<String>,