app-server: use permission ids and runtime workspace roots

This commit is contained in:
Michael Bolin
2026-05-14 19:02:42 -07:00
parent 3a23e87e20
commit 98c9b9914b
58 changed files with 1165 additions and 676 deletions

View File

@@ -2296,6 +2296,7 @@ mod tests {
model_provider: "openai".to_string(),
service_tier: None,
cwd,
runtime_workspace_roots: Vec::new(),
instruction_sources: vec![absolute_path("/tmp/AGENTS.md")],
approval_policy: v2::AskForApproval::OnFailure,
approvals_reviewer: v2::ApprovalsReviewer::User,
@@ -2340,6 +2341,7 @@ mod tests {
"modelProvider": "openai",
"serviceTier": null,
"cwd": absolute_path_string("tmp"),
"runtimeWorkspaceRoots": [],
"instructionSources": [absolute_path_string("tmp/AGENTS.md")],
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",

View File

@@ -21,7 +21,9 @@ 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;
@@ -456,31 +458,100 @@ impl From<ActivePermissionProfile> for CoreActivePermissionProfile {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum PermissionProfileSelectionParams {
/// Select a named built-in or user-defined profile and optionally apply
/// bounded modifications that Codex knows how to validate.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Profile {
id: String,
#[ts(optional = nullable)]
modifications: Option<Vec<PermissionProfileModificationParams>>,
},
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionProfileSelectionParams {
id: String,
legacy_additional_writable_roots: Vec<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum PermissionProfileModificationParams {
/// Additional concrete directory that should be writable.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
AdditionalWritableRoot { path: 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)]

View File

@@ -655,6 +655,61 @@ fn permissions_request_approval_response_accepts_strict_auto_review() {
assert_eq!(response.strict_auto_review, Some(true));
}
#[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(),
}))
.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);
let turn: TurnStartParams = serde_json::from_value(json!({
"threadId": "thread-1",
"input": [],
"permissions": params["permissions"].clone(),
}))
.expect("turn/start params deserialize");
assert_legacy_permission_profile_selection(turn.permissions, &additional_root);
}
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");
assert_eq!(
selection.legacy_additional_writable_roots(),
std::slice::from_ref(additional_root)
);
}
#[test]
fn fs_get_metadata_response_round_trips_minimal_fields() {
let response = FsGetMetadataResponse {
@@ -3469,6 +3524,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
responsesapi_client_metadata: None,
environments: None,
cwd: None,
runtime_workspace_roots: None,
approval_policy: None,
approvals_reviewer: None,
sandbox_policy: None,

View File

@@ -107,6 +107,11 @@ pub struct ThreadStartParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Replace the thread's runtime workspace roots. Relative paths are
/// resolved against the effective cwd for the thread.
#[experimental("thread/start.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -116,10 +121,10 @@ pub struct ThreadStartParams {
pub approvals_reviewer: Option<ApprovalsReviewer>,
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// Named profile selection for this thread. Cannot be combined with
/// `sandbox`. Use bounded `modifications` for supported turn/thread
/// adjustments instead of replacing the full permissions profile.
/// 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>,
#[ts(optional = nullable)]
@@ -195,6 +200,11 @@ pub struct ThreadStartResponse {
pub model_provider: String,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Thread-scoped runtime workspace roots used to materialize
/// `:workspace_roots`.
#[experimental("thread/start.runtimeWorkspaceRoots")]
#[serde(default)]
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
@@ -264,6 +274,11 @@ pub struct ThreadResumeParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Replace the thread's runtime workspace roots. Relative paths are
/// resolved against the effective cwd for the thread.
#[experimental("thread/resume.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -273,10 +288,11 @@ pub struct ThreadResumeParams {
pub approvals_reviewer: Option<ApprovalsReviewer>,
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// Named profile selection for the resumed thread. Cannot be combined
/// with `sandbox`. Use bounded `modifications` for supported thread
/// adjustments instead of replacing the full permissions profile.
/// 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>,
#[ts(optional = nullable)]
@@ -310,6 +326,11 @@ pub struct ThreadResumeResponse {
pub model_provider: String,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Thread-scoped runtime workspace roots used to materialize
/// `:workspace_roots`.
#[experimental("thread/resume.runtimeWorkspaceRoots")]
#[serde(default)]
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
@@ -370,6 +391,11 @@ pub struct ThreadForkParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Replace the thread's runtime workspace roots. Relative paths are
/// resolved against the effective cwd for the thread.
#[experimental("thread/fork.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -379,10 +405,11 @@ pub struct ThreadForkParams {
pub approvals_reviewer: Option<ApprovalsReviewer>,
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// Named profile selection for the forked thread. Cannot be combined with
/// `sandbox`. Use bounded `modifications` for supported thread
/// adjustments instead of replacing the full permissions profile.
/// 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>,
#[ts(optional = nullable)]
@@ -419,6 +446,11 @@ pub struct ThreadForkResponse {
pub model_provider: String,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Thread-scoped runtime workspace roots used to materialize
/// `:workspace_roots`.
#[experimental("thread/fork.runtimeWorkspaceRoots")]
#[serde(default)]
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,

View File

@@ -64,6 +64,12 @@ pub struct TurnStartParams {
/// Override the working directory for this turn and subsequent turns.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
/// Replace the thread's runtime workspace roots for this turn and
/// subsequent turns. Relative paths are resolved against the effective
/// cwd for the turn.
#[experimental("turn/start.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
/// Override the approval policy for this turn and subsequent turns.
#[experimental(nested)]
#[ts(optional = nullable)]
@@ -75,11 +81,11 @@ pub struct TurnStartParams {
/// Override the sandbox policy for this turn and subsequent turns.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Select a named permissions profile for this turn and subsequent turns.
/// Cannot be combined with `sandboxPolicy`. Use bounded `modifications`
/// for supported turn adjustments instead of replacing the full
/// permissions profile.
/// 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>,
/// Override the model for this turn and subsequent turns.