Move workspace roots onto thread/session state and stop using active permission profile modifications as an overlay for writable roots. Existing app-server threads now preserve their persisted PermissionProfile value across resume, fork, and turn updates; permissions requests on existing threads only update the active named profile after validating it exists. Workspace roots can be updated independently, and SandboxPolicy::WorkspaceWrite no longer stores its own writable_roots.

This commit is contained in:
Michael Bolin
2026-05-05 16:01:43 -07:00
parent 9e0c191c13
commit b450042d45
131 changed files with 1698 additions and 1781 deletions
+9 -12
View File
@@ -88,7 +88,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
- Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected.
- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread.
The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`.
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or experimental `permissions` profile selection, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running.
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, workspace roots, approval policy, approvals reviewer, and the active permission profile name. The permission profile value associated with the thread is not mutable through `turn/start`. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running.
- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes).
- Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage.
@@ -143,8 +143,8 @@ 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/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/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. The stored permission profile value is preserved; clients may update `workspaceRoots` or use `permissions` to update the active profile name after validating that the profile id exists. The legacy `sandbox` shorthand is not accepted for resume.
- `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. Like resume, fork preserves the source permission profile value while allowing explicit `workspaceRoots` or active profile name updates.
- `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/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.
@@ -167,7 +167,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. It can update `workspaceRoots` and use experimental `permissions` profile selection to update the active profile name, but it cannot change the thread's permission profile value; `sandboxPolicy` is rejected. 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"`.
@@ -244,6 +244,7 @@ Start a fresh thread when you need a new Codex conversation.
// current config settings.
"model": "gpt-5.1-codex",
"cwd": "/Users/me/project",
"workspaceRoots": ["/Users/me/project"],
"approvalPolicy": "never",
"sandbox": "workspaceWrite",
// Prefer experimental profile selection:
@@ -281,7 +282,7 @@ Start a fresh thread when you need a new Codex conversation.
Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can pass non-permission configuration overrides such as `approvalsReviewer`; the stored permission profile value is preserved unless you start a new thread. Use `workspaceRoots` to replace the thread root list, and use `permissions` only to update the active profile name after validating the id exists.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage.
@@ -635,19 +636,15 @@ You can optionally specify config overrides on the new turn. If specified, these
"input": [ { "type": "text", "text": "Run tests" } ],
// Below are optional config overrides
"cwd": "/Users/me/project",
"workspaceRoots": ["/Users/me/project", "/Users/me/project/packages/api"],
// Experimental: turn-scoped environment selection.
"environments": [
{ "environmentId": "local", "cwd": "/Users/me/project" }
],
"approvalPolicy": "unlessTrusted",
"sandboxPolicy": {
"type": "workspaceWrite",
"writableRoots": ["/Users/me/project"],
"networkAccess": true
},
// Prefer experimental profile selection:
// Optional: validate and record the active profile name without changing
// the thread's permission profile value.
// "permissions": { "type": "profile", "id": ":workspace" }
// Do not send both "sandboxPolicy" and "permissions".
"model": "gpt-5.1-codex",
"effort": "medium",
"summary": "concise",
@@ -760,6 +760,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
}],
responsesapi_client_metadata: None,
cwd: None,
workspace_roots: None,
approval_policy: None,
sandbox_policy: None,
permissions: None,
@@ -102,7 +102,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;
@@ -343,6 +342,8 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
#[cfg(test)]
use codex_protocol::items::TurnItem;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AgentStatus;
@@ -200,7 +200,8 @@ impl CommandExecRequestProcessor {
} else {
ExecCapturePolicy::ShellTool
};
let sandbox_cwd = if permission_profile.is_some() {
let has_request_permission_profile = permission_profile.is_some();
let sandbox_cwd = if has_request_permission_profile {
cwd.clone()
} else {
self.config.cwd.clone()
@@ -283,10 +284,16 @@ impl CommandExecRequestProcessor {
None => None,
};
let workspace_roots = if has_request_permission_profile {
vec![sandbox_cwd.clone()]
} else {
self.config.workspace_roots.clone()
};
let exec_request = codex_core::exec::build_exec_request(
exec_params,
&effective_permission_profile,
&sandbox_cwd,
&workspace_roots,
&codex_linux_sandbox_exe,
use_legacy_landlock,
)
@@ -591,11 +591,13 @@ pub(super) async fn handle_pending_thread_resume_request(
permission_profile,
active_permission_profile,
cwd,
workspace_roots,
reasoning_effort,
..
} = pending.config_snapshot;
let instruction_sources = pending.instruction_sources;
let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
let sandbox =
thread_response_sandbox_policy(&permission_profile, &workspace_roots, cwd.as_path());
let active_permission_profile =
thread_response_active_permission_profile(active_permission_profile);
@@ -609,6 +611,7 @@ pub(super) async fn handle_pending_thread_resume_request(
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox,
workspace_roots,
permission_profile: Some(permission_profile.into()),
active_permission_profile,
reasoning_effort,
@@ -16,6 +16,99 @@ struct ThreadListFilters {
use_state_db_only: bool,
}
#[derive(Clone)]
struct PersistedThreadPermissionState {
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
workspace_roots: Vec<AbsolutePathBuf>,
}
fn absolute_path_from_history_path(
path: &Path,
base: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
if let Ok(path) = AbsolutePathBuf::try_from(path) {
Some(path)
} else if let Some(base) = base {
Some(AbsolutePathBuf::resolve_path_against_base(
path,
base.as_path(),
))
} else {
AbsolutePathBuf::relative_to_current_dir(path).ok()
}
}
fn roots_or_cwd(
roots: Vec<AbsolutePathBuf>,
cwd: Option<&AbsolutePathBuf>,
) -> Vec<AbsolutePathBuf> {
if roots.is_empty() {
cwd.cloned().into_iter().collect()
} else {
roots
}
}
fn persisted_thread_permission_state(
history: &InitialHistory,
fallback_cwd: Option<&Path>,
fallback_sandbox_policy: Option<&codex_protocol::protocol::SandboxPolicy>,
) -> Option<PersistedThreadPermissionState> {
let mut cwd =
fallback_cwd.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None));
let mut workspace_roots = None;
let mut permission_profile = None;
let mut active_permission_profile = None;
for item in history.get_rollout_items() {
match item {
RolloutItem::SessionMeta(meta_line) => {
cwd = absolute_path_from_history_path(meta_line.meta.cwd.as_path(), cwd.as_ref())
.or(cwd);
workspace_roots = Some(roots_or_cwd(meta_line.meta.workspace_roots, cwd.as_ref()));
}
RolloutItem::TurnContext(context) => {
cwd = absolute_path_from_history_path(context.cwd.as_path(), cwd.as_ref()).or(cwd);
workspace_roots = Some(roots_or_cwd(context.workspace_roots, cwd.as_ref()));
let context_cwd = cwd
.as_ref()
.map(AbsolutePathBuf::as_path)
.unwrap_or(context.cwd.as_path());
permission_profile = Some(context.permission_profile.unwrap_or_else(|| {
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
&context.sandbox_policy,
context_cwd,
)
}));
}
RolloutItem::EventMsg(EventMsg::SessionConfigured(event)) => {
cwd = Some(event.cwd.clone());
workspace_roots = Some(roots_or_cwd(event.workspace_roots, cwd.as_ref()));
permission_profile = Some(event.permission_profile);
active_permission_profile = event.active_permission_profile;
}
RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) => {
}
}
}
if permission_profile.is_none() {
let cwd = cwd.as_ref()?;
let fallback_sandbox_policy = fallback_sandbox_policy?;
permission_profile = Some(PermissionProfile::from_legacy_sandbox_policy_for_cwd(
fallback_sandbox_policy,
cwd.as_path(),
));
}
Some(PersistedThreadPermissionState {
permission_profile: permission_profile?,
active_permission_profile,
workspace_roots: roots_or_cwd(workspace_roots.unwrap_or_default(), cwd.as_ref()),
})
}
fn collect_resume_override_mismatches(
request: &ThreadResumeParams,
config_snapshot: &ThreadConfigSnapshot,
@@ -713,6 +806,7 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -745,6 +839,7 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -1059,6 +1154,7 @@ impl ThreadRequestProcessor {
let sandbox = thread_response_sandbox_policy(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
config_snapshot.cwd.as_path(),
);
let active_permission_profile =
@@ -1074,6 +1170,7 @@ impl ThreadRequestProcessor {
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox,
workspace_roots: config_snapshot.workspace_roots,
permission_profile: Some(config_snapshot.permission_profile.into()),
active_permission_profile,
reasoning_effort: config_snapshot.reasoning_effort,
@@ -1106,6 +1203,7 @@ impl ThreadRequestProcessor {
model_provider: Option<String>,
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
cwd: Option<String>,
workspace_roots: Option<Vec<AbsolutePathBuf>>,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox: Option<SandboxMode>,
@@ -1119,6 +1217,8 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd: cwd.map(PathBuf::from),
workspace_roots: workspace_roots
.map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()),
approval_policy: approval_policy
.map(codex_app_server_protocol::AskForApproval::to_core),
approvals_reviewer: approvals_reviewer
@@ -1135,6 +1235,40 @@ impl ThreadRequestProcessor {
overrides
}
async fn validate_active_permission_profile_selection(
&self,
permissions: PermissionProfileSelectionParams,
request_overrides: Option<HashMap<String, serde_json::Value>>,
cwd: Option<PathBuf>,
fallback_cwd: Option<PathBuf>,
) -> Result<ActivePermissionProfile, JSONRPCErrorError> {
let mut overrides = ConfigOverrides {
cwd,
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()
};
apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(permissions));
let config = self
.config_manager
.load_for_cwd(request_overrides, overrides, fallback_cwd)
.await
.map_err(|err| config_load_error(&err))?;
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid permission profile selection: {warning}"
)));
}
config
.permissions
.active_permission_profile()
.ok_or_else(|| {
invalid_request("permission profile selection did not resolve to a named profile")
})
}
fn parse_environment_selections(
&self,
environments: Option<Vec<TurnEnvironmentParams>>,
@@ -2199,6 +2333,15 @@ impl ThreadRequestProcessor {
.await;
return Ok(());
}
if params.sandbox.is_some() {
self.outgoing
.send_error(
request_id,
invalid_request("`sandbox` cannot be used to change thread permissions"),
)
.await;
return Ok(());
}
if params.persist_extended_history {
self.send_persist_extended_history_deprecation_notice(request_id.connection_id)
.await;
@@ -2228,9 +2371,10 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
sandbox: _sandbox,
permissions,
config: mut request_overrides,
base_instructions,
@@ -2258,19 +2402,68 @@ impl ThreadRequestProcessor {
};
let history_cwd = thread_history.session_cwd();
let persisted_permission_state = persisted_thread_permission_state(
&thread_history,
history_cwd.as_deref(),
resume_source_thread
.as_ref()
.map(|thread| &thread.sandbox_policy),
);
let active_permission_profile = if let Some(permissions) = permissions {
match self
.validate_active_permission_profile_selection(
permissions,
request_overrides.clone(),
cwd.clone().map(PathBuf::from),
history_cwd.clone(),
)
.await
{
Ok(active_permission_profile) => Some(active_permission_profile),
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return Ok(());
}
}
} else {
persisted_permission_state
.as_ref()
.and_then(|state| state.active_permission_profile.clone())
};
let workspace_roots_were_explicit = workspace_roots.is_some();
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
permissions,
/*sandbox*/ None,
/*permissions*/ None,
base_instructions,
developer_instructions,
personality,
);
if let Some(persisted_permission_state) = persisted_permission_state {
typesafe_overrides.permission_profile =
Some(persisted_permission_state.permission_profile.clone());
if !workspace_roots_were_explicit {
typesafe_overrides.workspace_roots = Some(
persisted_permission_state
.workspace_roots
.iter()
.map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf)
.collect(),
);
}
} else if !workspace_roots_were_explicit
&& let Some(root) = history_cwd
.as_deref()
.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None))
{
typesafe_overrides.workspace_roots = Some(vec![root.to_path_buf()]);
}
self.load_and_apply_persisted_resume_metadata(
&thread_history,
&mut request_overrides,
@@ -2279,7 +2472,7 @@ impl ThreadRequestProcessor {
.await;
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match self
let mut config = match self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
@@ -2291,6 +2484,7 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
config.permissions.active_permission_profile = active_permission_profile;
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let response_history = thread_history.clone();
@@ -2369,6 +2563,7 @@ impl ThreadRequestProcessor {
let config_snapshot = codex_thread.config_snapshot().await;
let sandbox = thread_response_sandbox_policy(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
config_snapshot.cwd.as_path(),
);
let active_permission_profile = thread_response_active_permission_profile(
@@ -2385,6 +2580,7 @@ impl ThreadRequestProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox,
workspace_roots: config_snapshot.workspace_roots,
permission_profile: Some(config_snapshot.permission_profile.into()),
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
@@ -2820,6 +3016,7 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -2837,6 +3034,11 @@ impl ThreadRequestProcessor {
"`permissions` cannot be combined with `sandbox`",
));
}
if sandbox.is_some() {
return Err(invalid_request(
"`sandbox` cannot be used to change thread permissions",
));
}
if persist_extended_history {
self.send_persist_extended_history_deprecation_notice(request_id.connection_id)
.await;
@@ -2879,26 +3081,62 @@ impl ThreadRequestProcessor {
} else {
Some(cli_overrides)
};
let fork_history = InitialHistory::Forked(history_items.clone());
let persisted_permission_state = persisted_thread_permission_state(
&fork_history,
history_cwd.as_deref(),
Some(&source_thread.sandbox_policy),
)
.ok_or_else(|| {
invalid_request("thread history is missing persisted permission configuration")
})?;
let active_permission_profile = if let Some(permissions) = permissions {
Some(
self.validate_active_permission_profile_selection(
permissions,
request_overrides.clone(),
cwd.clone().map(PathBuf::from),
history_cwd.clone(),
)
.await?,
)
} else {
persisted_permission_state.active_permission_profile.clone()
};
let workspace_roots_were_explicit = workspace_roots.is_some();
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
permissions,
/*sandbox*/ None,
/*permissions*/ None,
base_instructions,
developer_instructions,
/*personality*/ None,
);
typesafe_overrides.permission_profile =
Some(persisted_permission_state.permission_profile.clone());
if !workspace_roots_were_explicit {
typesafe_overrides.workspace_roots = Some(
persisted_permission_state
.workspace_roots
.iter()
.map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf)
.collect(),
);
}
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = self
let mut config = self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
.map_err(|err| config_load_error(&err))?;
config.permissions.active_permission_profile = active_permission_profile;
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;
@@ -2984,6 +3222,7 @@ impl ThreadRequestProcessor {
let config_snapshot = forked_thread.config_snapshot().await;
let sandbox = thread_response_sandbox_policy(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
config_snapshot.cwd.as_path(),
);
let active_permission_profile =
@@ -2999,6 +3238,7 @@ impl ThreadRequestProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox,
workspace_roots: config_snapshot.workspace_roots,
permission_profile: Some(config_snapshot.permission_profile.into()),
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
@@ -516,6 +516,7 @@ mod thread_processor_behavior_tests {
model_provider: None,
service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)),
cwd: None,
workspace_roots: None,
approval_policy: None,
approvals_reviewer: None,
sandbox: None,
@@ -536,6 +537,7 @@ mod thread_processor_behavior_tests {
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
active_permission_profile: None,
cwd,
workspace_roots: Vec::new(),
ephemeral: false,
reasoning_effort: None,
personality: None,
@@ -174,30 +174,24 @@ 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(PermissionProfileSelectionParams::Profile { id }) = 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()
}
},
));
}
pub(super) fn thread_response_sandbox_policy(
permission_profile: &codex_protocol::models::PermissionProfile,
workspace_roots: &[AbsolutePathBuf],
cwd: &Path,
) -> codex_app_server_protocol::SandboxPolicy {
let file_system_policy = permission_profile.file_system_sandbox_policy();
let materialized_permission_profile =
permission_profile.materialize_project_roots_with_workspace_roots(workspace_roots);
let file_system_policy = materialized_permission_profile.file_system_sandbox_policy();
let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile(
permission_profile,
&materialized_permission_profile,
&file_system_policy,
permission_profile.network_sandbox_policy(),
materialized_permission_profile.network_sandbox_policy(),
cwd,
);
sandbox_policy.into()
@@ -354,6 +354,7 @@ impl TurnRequestProcessor {
let turn_has_input = !mapped_items.is_empty();
let has_any_overrides = params.cwd.is_some()
|| params.workspace_roots.is_some()
|| params.approval_policy.is_some()
|| params.approvals_reviewer.is_some()
|| params.sandbox_policy.is_some()
@@ -370,52 +371,55 @@ impl TurnRequestProcessor {
"`permissions` cannot be combined with `sandboxPolicy`",
));
}
if params.sandbox_policy.is_some() {
return Err(invalid_request(
"`sandboxPolicy` cannot be used to change thread permissions",
));
}
let cwd = params.cwd;
let workspace_roots = params.workspace_roots;
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) =
if let Some(permissions) = params.permissions {
let snapshot = thread.config_snapshot().await;
let mut overrides = ConfigOverrides {
cwd: cwd.clone(),
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()
};
apply_permission_profile_selection_to_config_overrides(
&mut overrides,
Some(permissions),
);
let config = self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
overrides,
Some(snapshot.cwd.to_path_buf()),
)
.await
.map_err(|err| config_load_error(&err))?;
// Startup config is allowed to fall back when requirements
// disallow a configured profile. An explicit turn request
// is different: reject it before accepting user input.
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid turn context override: {warning}"
)));
}
(
Some(config.permissions.permission_profile()),
config.permissions.active_permission_profile(),
)
} else {
(None, None)
let active_permission_profile = if let Some(permissions) = params.permissions {
let snapshot = thread.config_snapshot().await;
let mut overrides = ConfigOverrides {
cwd: cwd.clone(),
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()
};
apply_permission_profile_selection_to_config_overrides(
&mut overrides,
Some(permissions),
);
let config = self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
overrides,
Some(snapshot.cwd.to_path_buf()),
)
.await
.map_err(|err| config_load_error(&err))?;
// Startup config is allowed to fall back when requirements
// disallow a configured profile. An explicit turn request
// is different: reject it before accepting user input.
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid turn context override: {warning}"
)));
}
config.permissions.active_permission_profile()
} else {
None
};
let permission_profile = None;
let model = params.model;
let effort = params.effort.map(Some);
let summary = params.summary;
@@ -429,6 +433,9 @@ impl TurnRequestProcessor {
thread
.validate_turn_context_overrides(CodexThreadTurnContextOverrides {
cwd: cwd.clone(),
workspace_roots: workspace_roots
.clone()
.map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()),
approval_policy,
approvals_reviewer,
sandbox_policy: sandbox_policy.clone(),
@@ -454,6 +461,7 @@ impl TurnRequestProcessor {
final_output_json_schema: params.output_schema,
responsesapi_client_metadata: params.responsesapi_client_metadata,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
@@ -135,6 +135,7 @@ pub fn create_fake_rollout_with_source(
forked_from_id: None,
timestamp: meta_rfc3339.to_string(),
cwd: PathBuf::from("/"),
workspace_roots: Vec::new(),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source,
@@ -218,6 +219,7 @@ pub fn create_fake_rollout_with_text_elements(
forked_from_id: None,
timestamp: meta_rfc3339.to_string(),
cwd: PathBuf::from("/"),
workspace_roots: Vec::new(),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
@@ -668,6 +668,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
approvals_reviewer: None,
sandbox: None,
permissions: None,
workspace_roots: None,
config: None,
service_name: None,
base_instructions: None,
@@ -610,6 +610,7 @@ sqlite = true
codex_home: codex_home.path().to_path_buf(),
sqlite_home: codex_home.path().to_path_buf(),
cwd: codex_home.path().to_path_buf(),
workspace_roots: Vec::new(),
model_provider_id: "mock_provider".to_string(),
generate_memories: false,
};
@@ -1123,6 +1123,7 @@ async fn seed_pathless_store_thread(
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
cwd: None,
workspace_roots: Vec::new(),
model_provider: "test-provider".to_string(),
memory_mode: ThreadMemoryMode::Disabled,
},
@@ -1176,6 +1176,7 @@ stream_max_retries = 0
forked_from_id: None,
timestamp: "2025-01-05T12:00:00Z".to_string(),
cwd: repo_path.clone(),
workspace_roots: Vec::new(),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: RolloutSessionSource::Cli,
@@ -327,7 +327,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills(
assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str()));
assert_eq!(
warning.message,
"Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list."
"Exceeded skills context budget of 2%. All skill descriptions were removed and 12 additional skills were not included in the model-visible skills list."
);
timeout(
@@ -706,7 +706,6 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn()
}],
permissions: Some(PermissionProfileSelectionParams::Profile {
id: ":danger-no-sandbox".to_string(),
modifications: None,
}),
..Default::default()
})
@@ -1582,7 +1581,6 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
text_elements: Vec::new(),
}],
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
@@ -1750,7 +1748,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> {
}
#[tokio::test]
async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
async fn turn_start_updates_cwd_without_replacing_workspace_roots_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let tmp = TempDir::new()?;
@@ -1804,7 +1802,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
// first turn with workspace-write sandbox and first_cwd
// first turn with first_cwd as the thread's workspace root
let first_turn = mcp
.send_turn_start_request(TurnStartParams {
environments: None,
@@ -1815,14 +1813,10 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
}],
responsesapi_client_metadata: None,
cwd: Some(first_cwd.clone()),
workspace_roots: Some(vec![first_cwd.try_into()?]),
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
approvals_reviewer: None,
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![first_cwd.try_into()?],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}),
sandbox_policy: None,
permissions: None,
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
@@ -1845,7 +1839,8 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
.await??;
mcp.clear_message_buffer();
// second turn with workspace-write and second_cwd, ensure exec begins in second_cwd
// second turn changes cwd only; workspace roots stay on first_cwd while
// exec begins in second_cwd.
let second_turn = mcp
.send_turn_start_request(TurnStartParams {
environments: None,
@@ -1856,9 +1851,10 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
}],
responsesapi_client_metadata: None,
cwd: Some(second_cwd.clone()),
workspace_roots: None,
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
approvals_reviewer: None,
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
sandbox_policy: None,
permissions: None,
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
@@ -3252,7 +3248,6 @@ async fn command_execution_notifications_include_process_id() -> Result<()> {
text: "run a command".to_string(),
text_elements: Vec::new(),
}],
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
..Default::default()
})
.await?;
@@ -3348,7 +3343,7 @@ async fn command_execution_notifications_include_process_id() -> Result<()> {
}
#[tokio::test]
async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> Result<()> {
async fn turn_start_rejects_sandbox_policy_and_does_not_persist_project_trust() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
@@ -3390,16 +3385,25 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R
..Default::default()
})
.await?;
timeout(
let err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_request)),
mcp.read_stream_until_error_message(RequestId::Integer(turn_request)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
err.error.message,
"`sandboxPolicy` cannot be used to change thread permissions"
);
let turn_started = tokio::time::timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
.await;
assert!(
turn_started.is_err(),
"did not expect a turn/started notification after rejected sandboxPolicy"
);
let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config_toml.contains("trust_level = \"trusted\""));
@@ -104,6 +104,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
cwd: Some(workspace.to_string_lossy().into_owned()),
sandbox: Some(codex_app_server_protocol::SandboxMode::DangerFullAccess),
..Default::default()
})
.await?;
@@ -123,7 +124,6 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
}],
cwd: Some(workspace.clone()),
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
model: Some("mock-model".to_string()),
effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),
@@ -515,6 +515,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
cwd: Some(workspace.to_string_lossy().into_owned()),
sandbox: Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite),
..Default::default()
})
.await?;
@@ -533,13 +534,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
text_elements: Vec::new(),
}],
cwd: Some(workspace.clone()),
workspace_roots: Some(vec![workspace.clone().try_into()?]),
approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted),
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.clone().try_into()?],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}),
sandbox_policy: None,
model: Some("mock-model".to_string()),
effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),