mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
app-server: use permission ids and runtime workspace roots (#22611)
## Why This PR builds on [#22610](https://github.com/openai/codex/pull/22610) and is the app-server side of the migration from mutable per-turn `SandboxPolicy` replacement toward selecting immutable permission profiles by id plus mutable runtime workspace roots. Once permission profiles can carry their own immutable `workspace_roots`, app-server no longer needs to mutate the selected `PermissionProfile` just to represent thread-specific filesystem context. The mutable part now lives on the thread as explicit `runtimeWorkspaceRoots`, while `:workspace_roots` remains symbolic until the sandbox is realized for a turn. ## What Changed - Replaced the v2 permission-selection wrapper surface with plain profile ids for `thread/start`, `thread/resume`, `thread/fork`, and `turn/start`. - Removed the API surface for profile modifications (`PermissionProfileSelectionParams`, `PermissionProfileModificationParams`, `ActivePermissionProfileModification`). - Added experimental `runtimeWorkspaceRoots` fields to the thread lifecycle and turn-start APIs. - Threaded runtime workspace roots through core session/thread snapshots, turn overrides, app-server request handling, and command execution permission resolution. - Kept session permission state symbolic so later runtime root updates and cwd-only implicit-root retargeting rebind `:workspace_roots` correctly. - Updated the embedded clients just enough to send and restore the new thread state. - Refreshed the generated schema/TypeScript artifacts and the app-server README to match the new contract. ## Verification Targeted coverage for this layer lives in: - `codex-rs/app-server-protocol/src/protocol/v2/tests.rs` - `codex-rs/app-server/tests/suite/v2/thread_start.rs` - `codex-rs/app-server/tests/suite/v2/thread_resume.rs` - `codex-rs/app-server/tests/suite/v2/turn_start.rs` - `codex-rs/core/src/session/tests.rs` The key regression checks exercise that: - `runtimeWorkspaceRoots` resolve against the effective cwd on thread start. - Profile-declared workspace roots are excluded from the runtime workspace roots returned by app-server. - A turn-level runtime workspace-root update persists onto the thread and is returned by `thread/resume`. - A named permission profile selected on one turn remains symbolic so a later runtime-root-only turn update changes the actual sandbox writes. - A cwd-only turn update retargets the implicit runtime cwd root while preserving additional runtime roots. - The protocol fixtures and generated client artifacts stay in sync with the string-based permission selection contract. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22611). * #22612 * __->__ #22611
This commit is contained in:
committed by
GitHub
Unverified
parent
e6a7368810
commit
8a5306ff88
@@ -130,10 +130,10 @@ Example with notification opt-out:
|
||||
|
||||
## API Overview
|
||||
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
|
||||
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots, `permissionProfile` for the exact active runtime permissions, and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
@@ -156,7 +156,7 @@ Example with notification opt-out:
|
||||
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
|
||||
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
|
||||
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
|
||||
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
|
||||
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
|
||||
@@ -236,7 +236,9 @@ Start a fresh thread when you need a new Codex conversation.
|
||||
"approvalPolicy": "never",
|
||||
"sandbox": "workspaceWrite",
|
||||
// Prefer experimental profile selection:
|
||||
// "permissions": { "type": "profile", "id": ":workspace" }
|
||||
// "permissions": ":workspace"
|
||||
// Experimental runtime roots for :workspace_roots materialization:
|
||||
// "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"],
|
||||
// Do not send both "sandbox" and "permissions".
|
||||
"personality": "friendly",
|
||||
"serviceName": "my_app_server_client", // optional metrics tag (`service_name`)
|
||||
@@ -649,7 +651,9 @@ You can optionally specify config overrides on the new turn. If specified, these
|
||||
"networkAccess": true
|
||||
},
|
||||
// Prefer experimental profile selection:
|
||||
// "permissions": { "type": "profile", "id": ":workspace" }
|
||||
// "permissions": ":workspace"
|
||||
// Experimental runtime roots for :workspace_roots materialization:
|
||||
// "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"],
|
||||
// Do not send both "sandboxPolicy" and "permissions".
|
||||
"model": "gpt-5.1-codex",
|
||||
"effort": "medium",
|
||||
|
||||
@@ -659,6 +659,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
|
||||
}],
|
||||
responsesapi_client_metadata: None,
|
||||
cwd: None,
|
||||
runtime_workspace_roots: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
permissions: None,
|
||||
|
||||
@@ -103,7 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
|
||||
use codex_app_server_protocol::MockExperimentalMethodResponse;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::PermissionProfileModificationParams;
|
||||
use codex_app_server_protocol::PermissionProfileSelectionParams;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
|
||||
@@ -604,6 +604,7 @@ pub(super) async fn handle_pending_thread_resume_request(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
cwd,
|
||||
workspace_roots,
|
||||
reasoning_effort,
|
||||
..
|
||||
} = pending.config_snapshot;
|
||||
@@ -620,6 +621,7 @@ pub(super) async fn handle_pending_thread_resume_request(
|
||||
model_provider: model_provider_id,
|
||||
service_tier,
|
||||
cwd,
|
||||
runtime_workspace_roots: workspace_roots,
|
||||
instruction_sources,
|
||||
approval_policy: approval_policy.into(),
|
||||
approvals_reviewer: approvals_reviewer.into(),
|
||||
|
||||
@@ -59,6 +59,25 @@ fn collect_resume_override_mismatches(
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() {
|
||||
let base_cwd = request
|
||||
.cwd
|
||||
.as_deref()
|
||||
.map(|cwd| {
|
||||
AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path())
|
||||
})
|
||||
.unwrap_or_else(|| config_snapshot.cwd.clone());
|
||||
let requested_runtime_workspace_roots = requested_runtime_workspace_roots
|
||||
.iter()
|
||||
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()))
|
||||
.collect::<Vec<_>>();
|
||||
if requested_runtime_workspace_roots != config_snapshot.workspace_roots {
|
||||
mismatch_details.push(format!(
|
||||
"runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}",
|
||||
config_snapshot.workspace_roots
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(requested_approval) = request.approval_policy.as_ref() {
|
||||
let active_approval: AskForApproval = config_snapshot.approval_policy.into();
|
||||
if requested_approval != &active_approval {
|
||||
@@ -804,6 +823,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider,
|
||||
service_tier,
|
||||
cwd,
|
||||
runtime_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox,
|
||||
@@ -837,6 +857,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider,
|
||||
service_tier,
|
||||
cwd,
|
||||
runtime_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox,
|
||||
@@ -1173,6 +1194,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider: config_snapshot.model_provider_id,
|
||||
service_tier: config_snapshot.service_tier,
|
||||
cwd: config_snapshot.cwd,
|
||||
runtime_workspace_roots: config_snapshot.workspace_roots,
|
||||
instruction_sources,
|
||||
approval_policy: config_snapshot.approval_policy.into(),
|
||||
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
|
||||
@@ -1214,6 +1236,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider: Option<String>,
|
||||
service_tier: Option<Option<String>>,
|
||||
cwd: Option<String>,
|
||||
runtime_workspace_roots: Option<Vec<PathBuf>>,
|
||||
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
|
||||
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
|
||||
sandbox: Option<SandboxMode>,
|
||||
@@ -1227,6 +1250,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider,
|
||||
service_tier,
|
||||
cwd: cwd.map(PathBuf::from),
|
||||
workspace_roots: runtime_workspace_roots,
|
||||
approval_policy: approval_policy
|
||||
.map(codex_app_server_protocol::AskForApproval::to_core),
|
||||
approvals_reviewer: approvals_reviewer
|
||||
@@ -2351,6 +2375,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider,
|
||||
service_tier,
|
||||
cwd,
|
||||
runtime_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox,
|
||||
@@ -2386,6 +2411,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider,
|
||||
service_tier,
|
||||
cwd,
|
||||
runtime_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox,
|
||||
@@ -2523,6 +2549,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider: session_configured.model_provider_id,
|
||||
service_tier: session_configured.service_tier,
|
||||
cwd: session_configured.cwd,
|
||||
runtime_workspace_roots: config_snapshot.workspace_roots,
|
||||
instruction_sources,
|
||||
approval_policy: session_configured.approval_policy.into(),
|
||||
approvals_reviewer: session_configured.approvals_reviewer.into(),
|
||||
@@ -2987,6 +3014,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider,
|
||||
service_tier,
|
||||
cwd,
|
||||
runtime_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox,
|
||||
@@ -3052,6 +3080,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider,
|
||||
service_tier,
|
||||
cwd,
|
||||
runtime_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox,
|
||||
@@ -3181,6 +3210,7 @@ impl ThreadRequestProcessor {
|
||||
model_provider: session_configured.model_provider_id,
|
||||
service_tier: session_configured.service_tier,
|
||||
cwd: session_configured.cwd,
|
||||
runtime_workspace_roots: config_snapshot.workspace_roots,
|
||||
instruction_sources,
|
||||
approval_policy: session_configured.approval_policy.into(),
|
||||
approvals_reviewer: session_configured.approvals_reviewer.into(),
|
||||
|
||||
@@ -636,6 +636,7 @@ mod thread_processor_behavior_tests {
|
||||
model_provider: None,
|
||||
service_tier: Some(Some("priority".to_string())),
|
||||
cwd: None,
|
||||
runtime_workspace_roots: None,
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
sandbox: None,
|
||||
@@ -656,6 +657,8 @@ mod thread_processor_behavior_tests {
|
||||
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
|
||||
active_permission_profile: None,
|
||||
cwd,
|
||||
workspace_roots: Vec::new(),
|
||||
profile_workspace_roots: Vec::new(),
|
||||
ephemeral: false,
|
||||
reasoning_effort: None,
|
||||
personality: None,
|
||||
|
||||
@@ -179,19 +179,23 @@ pub(super) fn apply_permission_profile_selection_to_config_overrides(
|
||||
overrides: &mut ConfigOverrides,
|
||||
permissions: Option<PermissionProfileSelectionParams>,
|
||||
) {
|
||||
let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else {
|
||||
let Some(selection) = permissions else {
|
||||
return;
|
||||
};
|
||||
overrides.default_permissions = Some(id);
|
||||
overrides
|
||||
.additional_writable_roots
|
||||
.extend(modifications.unwrap_or_default().into_iter().map(
|
||||
|modification| match modification {
|
||||
PermissionProfileModificationParams::AdditionalWritableRoot { path } => {
|
||||
path.to_path_buf()
|
||||
}
|
||||
},
|
||||
));
|
||||
overrides.default_permissions = Some(selection.id().to_string());
|
||||
if selection.legacy_additional_writable_roots().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let legacy_roots = selection
|
||||
.legacy_additional_writable_roots()
|
||||
.iter()
|
||||
.map(AbsolutePathBuf::to_path_buf);
|
||||
if let Some(workspace_roots) = overrides.workspace_roots.as_mut() {
|
||||
workspace_roots.extend(legacy_roots);
|
||||
} else {
|
||||
overrides.additional_writable_roots.extend(legacy_roots);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn thread_response_sandbox_policy(
|
||||
|
||||
@@ -66,3 +66,43 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
|
||||
assert_eq!(summary, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_permission_profile_modifications_extend_runtime_roots() -> Result<()> {
|
||||
let root = if cfg!(windows) {
|
||||
AbsolutePathBuf::try_from("C:\\workspace-extra")?
|
||||
} else {
|
||||
AbsolutePathBuf::try_from("/workspace-extra")?
|
||||
};
|
||||
let selection = serde_json::from_value::<PermissionProfileSelectionParams>(json!({
|
||||
"type": "profile",
|
||||
"id": ":workspace",
|
||||
"modifications": [
|
||||
{
|
||||
"type": "additionalWritableRoot",
|
||||
"path": root,
|
||||
}
|
||||
],
|
||||
}))?;
|
||||
|
||||
let mut overrides = ConfigOverrides::default();
|
||||
apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection.clone()));
|
||||
assert_eq!(
|
||||
overrides.default_permissions,
|
||||
Some(":workspace".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.additional_writable_roots,
|
||||
vec![root.to_path_buf()]
|
||||
);
|
||||
|
||||
let mut overrides = ConfigOverrides {
|
||||
workspace_roots: Some(Vec::new()),
|
||||
..ConfigOverrides::default()
|
||||
};
|
||||
apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection));
|
||||
assert_eq!(overrides.additional_writable_roots, Vec::<PathBuf>::new());
|
||||
assert_eq!(overrides.workspace_roots, Some(vec![root.to_path_buf()]));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,20 @@ pub(crate) struct TurnRequestProcessor {
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
}
|
||||
|
||||
fn resolve_runtime_workspace_roots(
|
||||
workspace_roots: Vec<PathBuf>,
|
||||
base_cwd: &AbsolutePathBuf,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut resolved_roots = Vec::new();
|
||||
for path in workspace_roots {
|
||||
let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path());
|
||||
if !resolved_roots.iter().any(|existing| existing == &root) {
|
||||
resolved_roots.push(root);
|
||||
}
|
||||
}
|
||||
resolved_roots
|
||||
}
|
||||
|
||||
impl TurnRequestProcessor {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
@@ -355,8 +369,16 @@ impl TurnRequestProcessor {
|
||||
.map(V2UserInput::into_core)
|
||||
.collect();
|
||||
let turn_has_input = !mapped_items.is_empty();
|
||||
let runtime_workspace_roots_request = params.runtime_workspace_roots.clone();
|
||||
let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some()
|
||||
{
|
||||
Some(thread.config_snapshot().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let has_any_overrides = params.cwd.is_some()
|
||||
|| runtime_workspace_roots_request.is_some()
|
||||
|| params.approval_policy.is_some()
|
||||
|| params.approvals_reviewer.is_some()
|
||||
|| params.sandbox_policy.is_some()
|
||||
@@ -375,16 +397,45 @@ impl TurnRequestProcessor {
|
||||
}
|
||||
|
||||
let cwd = params.cwd;
|
||||
let runtime_workspace_roots = if let Some(workspace_roots) =
|
||||
runtime_workspace_roots_request.clone()
|
||||
{
|
||||
let Some(snapshot) = snapshot.as_ref() else {
|
||||
return Err(internal_error(
|
||||
"turn/start runtime workspace roots missing thread snapshot",
|
||||
));
|
||||
};
|
||||
let base_cwd = cwd
|
||||
.as_ref()
|
||||
.map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path()))
|
||||
.unwrap_or_else(|| snapshot.cwd.clone());
|
||||
Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let approval_policy = params.approval_policy.map(AskForApproval::to_core);
|
||||
let approvals_reviewer = params
|
||||
.approvals_reviewer
|
||||
.map(codex_app_server_protocol::ApprovalsReviewer::to_core);
|
||||
let sandbox_policy = params.sandbox_policy.map(|p| p.to_core());
|
||||
let (permission_profile, active_permission_profile) =
|
||||
let (permission_profile, active_permission_profile, profile_workspace_roots) =
|
||||
if let Some(permissions) = params.permissions {
|
||||
let snapshot = thread.config_snapshot().await;
|
||||
let Some(snapshot) = snapshot.as_ref() else {
|
||||
return Err(internal_error(
|
||||
"turn/start permission selection missing thread snapshot",
|
||||
));
|
||||
};
|
||||
let mut overrides = ConfigOverrides {
|
||||
cwd: cwd.clone(),
|
||||
workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else(
|
||||
|| {
|
||||
snapshot
|
||||
.workspace_roots
|
||||
.iter()
|
||||
.map(AbsolutePathBuf::to_path_buf)
|
||||
.collect()
|
||||
},
|
||||
)),
|
||||
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
|
||||
..Default::default()
|
||||
@@ -413,11 +464,12 @@ impl TurnRequestProcessor {
|
||||
)));
|
||||
}
|
||||
(
|
||||
Some(config.permissions.effective_permission_profile()),
|
||||
Some(config.permissions.permission_profile().clone()),
|
||||
config.permissions.active_permission_profile(),
|
||||
Some(config.permissions.profile_workspace_roots().to_vec()),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
(None, None, None)
|
||||
};
|
||||
let model = params.model;
|
||||
let effort = params.effort.map(Some);
|
||||
@@ -432,11 +484,13 @@ impl TurnRequestProcessor {
|
||||
thread
|
||||
.validate_turn_context_overrides(CodexThreadTurnContextOverrides {
|
||||
cwd: cwd.clone(),
|
||||
workspace_roots: runtime_workspace_roots.clone(),
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
permission_profile: permission_profile.clone(),
|
||||
active_permission_profile: active_permission_profile.clone(),
|
||||
profile_workspace_roots: profile_workspace_roots.clone(),
|
||||
windows_sandbox_level: None,
|
||||
model: model.clone(),
|
||||
effort,
|
||||
@@ -457,6 +511,8 @@ impl TurnRequestProcessor {
|
||||
final_output_json_schema: params.output_schema,
|
||||
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
||||
cwd,
|
||||
workspace_roots: runtime_workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
|
||||
@@ -614,6 +614,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
|
||||
model_provider: None,
|
||||
service_tier: None,
|
||||
cwd: None,
|
||||
runtime_workspace_roots: None,
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
sandbox: None,
|
||||
|
||||
@@ -184,6 +184,79 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let extra_root_tmp = TempDir::new()?;
|
||||
let extra_root = extra_root_tmp.path().join("extra-root");
|
||||
std::fs::create_dir_all(&extra_root)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread.id,
|
||||
exclude_turns: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse {
|
||||
runtime_workspace_roots,
|
||||
..
|
||||
} = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
|
||||
assert_eq!(
|
||||
runtime_workspace_roots,
|
||||
vec![AbsolutePathBuf::from_absolute_path(extra_root)?]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
@@ -236,6 +236,89 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
|
||||
|
||||
let cwd_tmp = TempDir::new()?;
|
||||
let cwd = cwd_tmp.path().to_path_buf();
|
||||
let relative_root = PathBuf::from("extra-root");
|
||||
std::fs::create_dir_all(cwd.join(&relative_root))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let req_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(cwd.to_string_lossy().to_string()),
|
||||
runtime_workspace_roots: Some(vec![relative_root.clone()]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
cwd: response_cwd,
|
||||
runtime_workspace_roots,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(resp)?;
|
||||
|
||||
assert_eq!(response_cwd, cwd.abs());
|
||||
assert_eq!(
|
||||
runtime_workspace_roots,
|
||||
vec![cwd_tmp.path().join(relative_root).abs()]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_roots() -> Result<()>
|
||||
{
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
let profile_root = TempDir::new()?;
|
||||
create_config_toml_with_profile_workspace_root(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
profile_root.path(),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let req_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(cwd.path().to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
runtime_workspace_roots,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(resp)?;
|
||||
|
||||
assert_eq!(
|
||||
runtime_workspace_roots,
|
||||
vec![cwd.path().to_path_buf().abs()]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -980,6 +1063,42 @@ stream_max_retries = 0
|
||||
)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_profile_workspace_root(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
profile_root: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
let profile_root_key = profile_root
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
default_permissions = "dev"
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
|
||||
[permissions.dev.workspace_roots]
|
||||
"{profile_root_key}" = true
|
||||
|
||||
[permissions.dev.filesystem.":workspace_roots"]
|
||||
"." = "write"
|
||||
"#,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_chatgpt_base_url(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
|
||||
@@ -34,7 +34,6 @@ use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::PatchChangeKind;
|
||||
use codex_app_server_protocol::PermissionProfileSelectionParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ServerRequestResolvedNotification;
|
||||
@@ -780,10 +779,11 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn()
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
permissions: Some(PermissionProfileSelectionParams::Profile {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
|
||||
modifications: None,
|
||||
}),
|
||||
permissions: Some(
|
||||
BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS
|
||||
.to_string()
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -1891,6 +1891,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
}],
|
||||
responsesapi_client_metadata: None,
|
||||
cwd: Some(first_cwd.clone()),
|
||||
runtime_workspace_roots: None,
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
|
||||
@@ -1932,6 +1933,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
}],
|
||||
responsesapi_client_metadata: None,
|
||||
cwd: Some(second_cwd.clone()),
|
||||
runtime_workspace_roots: None,
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
|
||||
@@ -1991,6 +1993,152 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_turns() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let tmp = TempDir::new()?;
|
||||
let codex_home = tmp.path().join("codex_home");
|
||||
std::fs::create_dir(&codex_home)?;
|
||||
let old_root = tmp.path().join("old-root");
|
||||
let new_root = tmp.path().join("new-root");
|
||||
std::fs::create_dir(&old_root)?;
|
||||
std::fs::create_dir(&new_root)?;
|
||||
let old_root_text = old_root.to_string_lossy().into_owned();
|
||||
let new_root_text = new_root.to_string_lossy().into_owned();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "done first"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-2", "done second"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let server_uri = server.uri();
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
default_permissions = "dev"
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
|
||||
[permissions.dev.filesystem.":workspace_roots"]
|
||||
"." = "write"
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let first_turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "select dev profile".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
runtime_workspace_roots: Some(vec![old_root]),
|
||||
permissions: Some("dev".to_string().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let second_turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "write in new root".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
runtime_workspace_roots: Some(vec![new_root]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 2, "expected two Responses API requests");
|
||||
let latest_permissions_instructions =
|
||||
|request: &core_test_support::responses::ResponsesRequest| {
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|text| text.contains("<permissions instructions>"))
|
||||
.expect("permissions instructions")
|
||||
};
|
||||
let first_permissions = latest_permissions_instructions(&requests[0]);
|
||||
assert!(first_permissions.contains(&old_root_text));
|
||||
assert!(
|
||||
!first_permissions.contains(&new_root_text),
|
||||
"first turn should materialize the initial runtime workspace root"
|
||||
);
|
||||
|
||||
let second_permissions = latest_permissions_instructions(&requests[1]);
|
||||
assert!(second_permissions.contains(&new_root_text));
|
||||
assert!(
|
||||
!second_permissions.contains(&old_root_text),
|
||||
"second turn should rebind :workspace_roots to the updated runtime workspace root"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
|
||||
Reference in New Issue
Block a user