permissions: move workspace roots onto thread state

This commit is contained in:
Michael Bolin
2026-05-12 23:22:45 -07:00
parent fbfbfe5fc5
commit 8a76af2354
168 changed files with 3246 additions and 3357 deletions

View File

@@ -223,6 +223,7 @@ macro_rules! client_request_definitions {
/// Typed response from the server to the client.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[allow(clippy::large_enum_variant)]
#[serde(tag = "method", rename_all = "camelCase")]
pub enum ClientResponse {
$(
@@ -2272,11 +2273,11 @@ mod tests {
model_provider: "openai".to_string(),
service_tier: None,
cwd,
workspace_roots: vec![],
instruction_sources: vec![absolute_path("/tmp/AGENTS.md")],
approval_policy: v2::AskForApproval::OnFailure,
approvals_reviewer: v2::ApprovalsReviewer::User,
sandbox: v2::SandboxPolicy::DangerFullAccess,
permission_profile: None,
active_permission_profile: None,
reasoning_effort: None,
},
@@ -2316,13 +2317,13 @@ mod tests {
"modelProvider": "openai",
"serviceTier": null,
"cwd": absolute_path_string("tmp"),
"workspaceRoots": [],
"instructionSources": [absolute_path_string("tmp/AGENTS.md")],
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": {
"type": "dangerFullAccess"
},
"permissionProfile": null,
"activePermissionProfile": null,
"reasoningEffort": null
}

View File

@@ -5,7 +5,6 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro
use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction;
use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions;
@@ -437,41 +436,6 @@ pub struct ActivePermissionProfile {
/// inheritance. This is currently always `null`.
#[serde(default)]
pub extends: Option<String>,
/// Bounded user-requested modifications applied on top of the named
/// profile, if any.
#[serde(default)]
pub modifications: Vec<ActivePermissionProfileModification>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum ActivePermissionProfileModification {
/// Additional concrete directory that should be writable.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
AdditionalWritableRoot { path: AbsolutePathBuf },
}
impl From<CoreActivePermissionProfileModification> for ActivePermissionProfileModification {
fn from(value: CoreActivePermissionProfileModification) -> Self {
match value {
CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => {
Self::AdditionalWritableRoot { path }
}
}
}
}
impl From<ActivePermissionProfileModification> for CoreActivePermissionProfileModification {
fn from(value: ActivePermissionProfileModification) -> Self {
match value {
ActivePermissionProfileModification::AdditionalWritableRoot { path } => {
Self::AdditionalWritableRoot { path }
}
}
}
}
impl From<CoreActivePermissionProfile> for ActivePermissionProfile {
@@ -479,11 +443,6 @@ impl From<CoreActivePermissionProfile> for ActivePermissionProfile {
Self {
id: value.id,
extends: value.extends,
modifications: value
.modifications
.into_iter()
.map(ActivePermissionProfileModification::from)
.collect(),
}
}
}
@@ -493,42 +452,10 @@ impl From<ActivePermissionProfile> for CoreActivePermissionProfile {
Self {
id: value.id,
extends: value.extends,
modifications: value
.modifications
.into_iter()
.map(CoreActivePermissionProfileModification::from)
.collect(),
}
}
}
#[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(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 },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -607,14 +534,16 @@ pub enum SandboxPolicy {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
WorkspaceWrite {
#[serde(default)]
writable_roots: Vec<AbsolutePathBuf>,
#[serde(default)]
network_access: bool,
#[serde(default)]
exclude_tmpdir_env_var: bool,
#[serde(default)]
exclude_slash_tmp: bool,
#[serde(default, skip_serializing)]
#[schemars(skip)]
#[ts(skip)]
legacy_writable_roots: Vec<AbsolutePathBuf>,
},
}
@@ -690,10 +619,10 @@ impl<'de> Deserialize<'de> for SandboxPolicy {
));
}
Ok(SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
legacy_writable_roots: writable_roots,
})
}
}
@@ -720,18 +649,60 @@ impl SandboxPolicy {
}
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
legacy_writable_roots: _,
} => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
network_access: *network_access,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
},
}
}
pub fn legacy_writable_roots(&self) -> &[AbsolutePathBuf] {
match self {
SandboxPolicy::WorkspaceWrite {
legacy_writable_roots,
..
} => legacy_writable_roots,
SandboxPolicy::DangerFullAccess
| SandboxPolicy::ReadOnly { .. }
| SandboxPolicy::ExternalSandbox { .. } => &[],
}
}
pub fn to_permission_profile_for_cwd(&self, cwd: &std::path::Path) -> CorePermissionProfile {
match self {
SandboxPolicy::WorkspaceWrite {
legacy_writable_roots,
..
} if legacy_writable_roots.is_empty() => {
CorePermissionProfile::from_legacy_sandbox_policy_for_cwd(&self.to_core(), cwd)
}
SandboxPolicy::WorkspaceWrite {
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
legacy_writable_roots,
} => CorePermissionProfile::workspace_write_with(
legacy_writable_roots,
if *network_access {
CoreNetworkSandboxPolicy::Enabled
} else {
CoreNetworkSandboxPolicy::Restricted
},
*exclude_tmpdir_env_var,
*exclude_slash_tmp,
),
SandboxPolicy::DangerFullAccess
| SandboxPolicy::ReadOnly { .. }
| SandboxPolicy::ExternalSandbox { .. } => {
CorePermissionProfile::from_legacy_sandbox_policy_for_cwd(&self.to_core(), cwd)
}
}
}
}
impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
@@ -752,15 +723,14 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
}
}
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
legacy_writable_roots: Vec::new(),
},
}
}

View File

@@ -2019,17 +2019,16 @@ fn mcp_server_elicitation_response_serializes_nullable_content() {
#[test]
fn sandbox_policy_round_trips_workspace_write_access() {
let v2_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
legacy_writable_roots: Vec::new(),
};
let core_policy = v2_policy.to_core();
assert_eq!(
core_policy,
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -2060,10 +2059,9 @@ fn sandbox_policy_deserializes_legacy_read_only_full_access_field() {
#[test]
fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() {
let writable_root = absolute_path("/workspace");
let policy = serde_json::from_value::<SandboxPolicy>(json!({
"type": "workspaceWrite",
"writableRoots": [writable_root],
"writableRoots": [],
"readOnlyAccess": {
"type": "fullAccess"
},
@@ -2075,14 +2073,38 @@ fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() {
assert_eq!(
policy,
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![absolute_path("/workspace")],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
legacy_writable_roots: Vec::new(),
}
);
}
#[test]
fn sandbox_policy_deserializes_legacy_workspace_write_writable_roots_field() {
let writable_root = absolute_path("/workspace");
let policy = serde_json::from_value::<SandboxPolicy>(json!({
"type": "workspaceWrite",
"writableRoots": [writable_root],
"networkAccess": false,
"excludeTmpdirEnvVar": false,
"excludeSlashTmp": false
}))
.expect("workspace-write policy should accept legacy writableRoots field");
assert_eq!(policy.legacy_writable_roots(), &[writable_root]);
assert_eq!(
serde_json::to_value(policy).expect("policy should serialize"),
json!({
"type": "workspaceWrite",
"networkAccess": false,
"excludeTmpdirEnvVar": false,
"excludeSlashTmp": false
})
);
}
#[test]
fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() {
let err = serde_json::from_value::<SandboxPolicy>(json!({
@@ -3392,9 +3414,6 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
assert_eq!(start.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.permission_profile, None);
assert_eq!(resume.permission_profile, None);
assert_eq!(fork.permission_profile, None);
assert_eq!(start.active_permission_profile, None);
assert_eq!(resume.active_permission_profile, None);
assert_eq!(fork.active_permission_profile, None);
@@ -3422,6 +3441,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
responsesapi_client_metadata: None,
environments: None,
cwd: None,
workspace_roots: None,
approval_policy: None,
approvals_reviewer: None,
sandbox_policy: None,
@@ -3439,6 +3459,20 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn turn_start_permissions_uses_profile_id_string_shape() {
let params: TurnStartParams = serde_json::from_value(json!({
"threadId": "thread-1",
"input": [],
"permissions": ":workspace"
}))
.expect("turn start params should deserialize");
assert_eq!(params.permissions, Some(":workspace".to_string()));
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(serialized["permissions"], json!(":workspace"));
}
#[test]
fn turn_start_params_round_trip_environments() {
let cwd = test_absolute_path();

View File

@@ -1,8 +1,6 @@
use super::ActivePermissionProfile;
use super::ApprovalsReviewer;
use super::AskForApproval;
use super::PermissionProfile;
use super::PermissionProfileSelectionParams;
use super::SandboxMode;
use super::SandboxPolicy;
use super::Thread;
@@ -107,6 +105,11 @@ pub struct ThreadStartParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Optional workspace roots for this thread. Omitted uses the server's
/// configured roots, usually seeded from `cwd`.
#[experimental("thread/start.workspaceRoots")]
#[ts(optional = nullable)]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -116,12 +119,11 @@ 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 permissions profile id for this new thread's initial permissions.
/// Cannot be combined with `sandbox`.
#[experimental("thread/start.permissions")]
#[ts(optional = nullable)]
pub permissions: Option<PermissionProfileSelectionParams>,
pub permissions: Option<String>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, JsonValue>>,
#[ts(optional = nullable)]
@@ -195,6 +197,11 @@ pub struct ThreadStartResponse {
pub model_provider: String,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Workspace roots used to realize symbolic `:project_roots` permission
/// entries for this thread.
#[experimental("thread/start.workspaceRoots")]
#[serde(default)]
pub workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
@@ -203,14 +210,8 @@ pub struct ThreadStartResponse {
/// Reviewer currently used for approval requests on this thread.
pub approvals_reviewer: ApprovalsReviewer,
/// Legacy sandbox policy retained for compatibility. Experimental clients
/// should prefer `permissionProfile` when they need exact runtime
/// permissions.
/// should prefer `activePermissionProfile` and `workspaceRoots`.
pub sandbox: SandboxPolicy,
/// Full active permissions for this thread. `activePermissionProfile`
/// carries display/provenance metadata for this runtime profile.
#[experimental("thread/start.permissionProfile")]
#[serde(default)]
pub permission_profile: Option<PermissionProfile>,
/// Named or implicit built-in profile that produced the active
/// permissions, when known.
#[experimental("thread/start.activePermissionProfile")]
@@ -264,6 +265,11 @@ pub struct ThreadResumeParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Optional replacement workspace roots for the resumed thread. Omitted
/// preserves the persisted or configured roots.
#[experimental("thread/resume.workspaceRoots")]
#[ts(optional = nullable)]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -271,14 +277,16 @@ pub struct ThreadResumeParams {
/// and subsequent turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Deprecated for resume. When present, the server treats this as a
/// compatibility spelling for selecting a matching named permissions
/// profile.
#[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 permissions profile id for the resumed thread. Cannot be combined
/// with `sandbox`.
#[experimental("thread/resume.permissions")]
#[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)]
@@ -310,6 +318,11 @@ pub struct ThreadResumeResponse {
pub model_provider: String,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Workspace roots used to realize symbolic `:project_roots` permission
/// entries for this thread.
#[experimental("thread/resume.workspaceRoots")]
#[serde(default)]
pub workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
@@ -318,14 +331,8 @@ pub struct ThreadResumeResponse {
/// Reviewer currently used for approval requests on this thread.
pub approvals_reviewer: ApprovalsReviewer,
/// Legacy sandbox policy retained for compatibility. Experimental clients
/// should prefer `permissionProfile` when they need exact runtime
/// permissions.
/// should prefer `activePermissionProfile` and `workspaceRoots`.
pub sandbox: SandboxPolicy,
/// Full active permissions for this thread. `activePermissionProfile`
/// carries display/provenance metadata for this runtime profile.
#[experimental("thread/resume.permissionProfile")]
#[serde(default)]
pub permission_profile: Option<PermissionProfile>,
/// Named or implicit built-in profile that produced the active
/// permissions, when known.
#[experimental("thread/resume.activePermissionProfile")]
@@ -370,6 +377,11 @@ pub struct ThreadForkParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Optional replacement workspace roots for the forked thread. Omitted
/// preserves the source thread roots when available.
#[experimental("thread/fork.workspaceRoots")]
#[ts(optional = nullable)]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -377,14 +389,16 @@ pub struct ThreadForkParams {
/// and subsequent turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Deprecated for fork. When present, the server treats this as a
/// compatibility spelling for selecting a matching named permissions
/// profile.
#[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 permissions profile id for the forked thread. Cannot be combined
/// with `sandbox`.
#[experimental("thread/fork.permissions")]
#[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)]
@@ -419,6 +433,11 @@ pub struct ThreadForkResponse {
pub model_provider: String,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Workspace roots used to realize symbolic `:project_roots` permission
/// entries for this thread.
#[experimental("thread/fork.workspaceRoots")]
#[serde(default)]
pub workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
@@ -427,14 +446,8 @@ pub struct ThreadForkResponse {
/// Reviewer currently used for approval requests on this thread.
pub approvals_reviewer: ApprovalsReviewer,
/// Legacy sandbox policy retained for compatibility. Experimental clients
/// should prefer `permissionProfile` when they need exact runtime
/// permissions.
/// should prefer `activePermissionProfile` and `workspaceRoots`.
pub sandbox: SandboxPolicy,
/// Full active permissions for this thread. `activePermissionProfile`
/// carries display/provenance metadata for this runtime profile.
#[experimental("thread/fork.permissionProfile")]
#[serde(default)]
pub permission_profile: Option<PermissionProfile>,
/// Named or implicit built-in profile that produced the active
/// permissions, when known.
#[experimental("thread/fork.activePermissionProfile")]

View File

@@ -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;
@@ -64,6 +63,11 @@ pub struct TurnStartParams {
/// Override the working directory for this turn and subsequent turns.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
/// Replace the workspace roots for this turn and subsequent turns. Omitted
/// preserves the current roots.
#[experimental("turn/start.workspaceRoots")]
#[ts(optional = nullable)]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Override the approval policy for this turn and subsequent turns.
#[experimental(nested)]
#[ts(optional = nullable)]
@@ -72,16 +76,16 @@ pub struct TurnStartParams {
/// subsequent turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Override the sandbox policy for this turn and subsequent turns.
/// Deprecated for turns. When present, the server treats this as a
/// compatibility spelling for selecting a matching named permissions
/// profile.
#[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")]
#[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>,