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:
Michael Bolin
2026-05-14 23:00:05 -07:00
committed by GitHub
Unverified
parent e6a7368810
commit 8a5306ff88
58 changed files with 1167 additions and 676 deletions
+9 -5
View File
@@ -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 agents 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 threads 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()?;