From 44831f4b8ac1925b7c80e56a5a63d8f80b9e7d7f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 11 May 2026 23:21:46 -0700 Subject: [PATCH 1/2] core: box multi-agent handler futures --- codex-rs/core/src/agent/control.rs | 71 ++-- .../handlers/multi_agents/close_agent.rs | 157 ++++---- .../handlers/multi_agents/resume_agent.rs | 201 +++++----- .../src/tools/handlers/multi_agents/spawn.rs | 303 +++++++-------- .../src/tools/handlers/multi_agents_tests.rs | 3 +- .../handlers/multi_agents_v2/close_agent.rs | 179 ++++----- .../tools/handlers/multi_agents_v2/spawn.rs | 348 +++++++++--------- 7 files changed, 656 insertions(+), 606 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 079ee61f01..0dddf9100f 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -64,6 +64,12 @@ pub(crate) struct LiveAgent { pub(crate) status: AgentStatus, } +#[derive(Clone, Copy)] +enum LiveAgentShutdownMode { + SubmitOnly, + WaitForTermination, +} + #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub(crate) struct ListedAgent { pub(crate) agent_name: String, @@ -233,32 +239,31 @@ impl AgentControl { // The same `AgentControl` is sent to spawn the thread. let new_thread = match (session_source, options.fork_mode.as_ref()) { (Some(session_source), Some(_)) => { - self.spawn_forked_thread( + Box::pin(self.spawn_forked_thread( &state, config, session_source, &options, inherited_shell_snapshot, inherited_exec_policy, - ) + )) .await? } (Some(session_source), None) => { - state - .spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - /*thread_source*/ Some(ThreadSource::Subagent), - /*persist_extended_history*/ false, - /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await? + Box::pin(state.spawn_new_thread_with_source( + config, + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + )) + .await? } - (None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?, + (None, _) => Box::pin(state.spawn_new_thread(config, self.clone())).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); @@ -713,22 +718,42 @@ impl AgentControl { /// Submit a shutdown request for a live agent without marking it explicitly closed in /// persisted spawn-edge state. pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { + self.shutdown_live_agent_with_mode(agent_id, LiveAgentShutdownMode::SubmitOnly) + .await + } + + async fn shutdown_live_agent_and_wait(&self, agent_id: ThreadId) -> CodexResult { + self.shutdown_live_agent_with_mode(agent_id, LiveAgentShutdownMode::WaitForTermination) + .await + } + + async fn shutdown_live_agent_with_mode( + &self, + agent_id: ThreadId, + mode: LiveAgentShutdownMode, + ) -> CodexResult { let state = self.upgrade()?; + let mut thread_to_wait = None; let result = if let Ok(thread) = state.get_thread(agent_id).await { thread.codex.session.ensure_rollout_materialized().await; thread.codex.session.flush_rollout().await?; - let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { + if matches!(thread.agent_status().await, AgentStatus::Shutdown) { Ok(String::new()) } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - thread.wait_until_terminated().await; - result + let result = state.send_op(agent_id, Op::Shutdown {}).await; + if result.is_ok() && matches!(mode, LiveAgentShutdownMode::WaitForTermination) { + thread_to_wait = Some(thread); + } + result + } } else { state.send_op(agent_id, Op::Shutdown {}).await }; let _ = state.remove_thread(&agent_id).await; self.state.release_spawned_thread(agent_id); + if let Some(thread) = thread_to_wait { + thread.wait_until_terminated().await; + } result } @@ -750,9 +775,9 @@ impl AgentControl { /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; - let result = self.shutdown_live_agent(agent_id).await; + let result = self.shutdown_live_agent_and_wait(agent_id).await; for descendant_id in descendant_ids { - match self.shutdown_live_agent(descendant_id).await { + match self.shutdown_live_agent_and_wait(descendant_id).await { Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} Err(err) => return Err(err), } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index e4788dd9b8..489315c507 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -20,84 +20,89 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = parse_agent_id_target(&args.target)?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = parse_agent_id_target(&args.target)?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); - } - }; - let result = Box::pin(session.services.agent_control.close_agent(agent_id)) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; - result?; + }; + let result = Box::pin(session.services.agent_control.close_agent(agent_id)) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; - Ok(CloseAgentResult { - previous_status: status, + Ok(CloseAgentResult { + previous_status: status, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index fa77bc37bb..6225bed414 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -22,111 +22,116 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: ResumeAgentArgs = parse_arguments(&arguments)?; - let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { - FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) - })?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); - let child_depth = next_thread_spawn_depth(&turn.session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: ResumeAgentArgs = parse_arguments(&arguments)?; + let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { + FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) + })?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + let child_depth = next_thread_spawn_depth(&turn.session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } - session - .send_event( - &turn, - CollabResumeBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + session + .send_event( + &turn, + CollabResumeBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + } + .into(), + ) + .await; + + let mut status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { + match Box::pin(try_resume_closed_agent( + &session, + &turn, receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - } - .into(), - ) - .await; - - let mut status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { - match Box::pin(try_resume_closed_agent( - &session, - &turn, - receiver_thread_id, - child_depth, - )) - .await - { - Ok(()) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - ( - session + child_depth, + )) + .await + { + Ok(()) => { + status = session .services .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or(receiver_agent), - None, - ) - } - Err(err) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - (receiver_agent, Some(err)) + .get_status(receiver_thread_id) + .await; + ( + session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or(receiver_agent), + None, + ) + } + Err(err) => { + status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + (receiver_agent, Some(err)) + } } + } else { + (receiver_agent, None) + }; + session + .send_event( + &turn, + CollabResumeEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + + if let Some(err) = error { + return Err(err); } - } else { - (receiver_agent, None) - }; - session - .send_event( - &turn, - CollabResumeEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; + turn.session_telemetry + .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); - if let Some(err) = error { - return Err(err); - } - turn.session_telemetry - .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); - - Ok(ResumeAgentResult { status }) + Ok(ResumeAgentResult { status }) + }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 0317658955..8159c3344f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -37,157 +37,162 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = render_input_preview(&input_items); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if args.fork_context { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( - config, - input_items, - Some(thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - /*task_name*/ None, - )?), - SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), - environments: Some(turn.environments.to_selections()), - }, - )) - .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = render_input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); } - None => None, - }; - let (_new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), - ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), - }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, - } - .into(), - ) - .await; - let new_thread_id = result?.thread_id; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if args.fork_context { + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort, + )?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); - Ok(SpawnAgentResult { - agent_id: new_thread_id.to_string(), - nickname, + let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + /*task_name*/ None, + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), + environments: Some(turn.environments.to_selections()), + }, + )) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (_new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let new_thread_id = result?.thread_id; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + + Ok(SpawnAgentResult { + agent_id: new_thread_id.to_string(), + nickname, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index b030cb010c..d1360186a0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3179,10 +3179,11 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr let parent_thread_id = parent.thread_id; let parent_session = parent.thread.codex.session.clone(); + let child_turn = parent_session.new_default_turn().await; let child_spawn_output = SpawnAgentHandler::default() .handle(invocation( parent_session.clone(), - parent_session.new_default_turn().await, + child_turn, "spawn_agent", function_payload(json!({"message": "hello child"})), )) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 6f2984c338..dc7221fc12 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -20,96 +20,101 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - if receiver_agent - .agent_path - .as_ref() - .is_some_and(AgentPath::is_root) - { - return Err(FunctionCallError::RespondToModel( - "root is not a spawned agent".to_string(), - )); - } - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + if receiver_agent + .agent_path + .as_ref() + .is_some_and(AgentPath::is_root) + { + return Err(FunctionCallError::RespondToModel( + "root is not a spawned agent".to_string(), + )); } - }; - let result = session - .services - .agent_control - .close_agent(agent_id) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); } - .into(), - ) - .await; - result?; + }; + let result = session + .services + .agent_control + .close_agent(agent_id) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; - Ok(CloseAgentResult { - previous_status: status, + Ok(CloseAgentResult { + previous_status: status, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 6642c5e4b0..289e7fbf4e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -39,188 +39,192 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let fork_mode = args.fork_mode()?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let fork_mode = args.fork_mode()?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); - let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); + let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; + let prompt = render_input_preview(&initial_operation); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let spawn_source = thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - Some(args.task_name.clone()), - )?; - let result = session - .services - .agent_control - .spawn_agent_with_metadata( - config, - match (spawn_source.get_agent_path(), initial_operation) { - (Some(recipient), Op::UserInput { items, .. }) - if items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) => - { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), } - (_, initial_operation) => initial_operation, - }, - Some(spawn_source), - SpawnAgentOptions { - fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), - fork_mode, - environments: Some(turn.environments.to_selections()), - }, + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort, + )?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let spawn_source = thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + Some(args.task_name.clone()), + )?; + let result = Box::pin( + session.services.agent_control.spawn_agent_with_metadata( + config, + match (spawn_source.get_agent_path(), initial_operation) { + (Some(recipient), Op::UserInput { items, .. }) + if items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) => + { + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + recipient, + Vec::new(), + prompt.clone(), + /*trigger_turn*/ true, + ), + } + } + (_, initial_operation) => initial_operation, + }, + Some(spawn_source), + SpawnAgentOptions { + fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), + fork_mode, + environments: Some(turn.environments.to_selections()), + }, + ), ) .await .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await - } - None => None, - }; - let (new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), + Err(_) => (None, None, AgentStatus::NotFound), }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await } - .into(), - ) - .await; - let _ = result?; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); - let task_name = new_agent_path.ok_or_else(|| { - FunctionCallError::RespondToModel( - "spawned agent is missing a canonical task name".to_string(), - ) - })?; + None => None, + }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let _ = result?; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + let task_name = new_agent_path.ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawned agent is missing a canonical task name".to_string(), + ) + })?; - let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; - if hide_agent_metadata { - Ok(SpawnAgentResult::HiddenMetadata { task_name }) - } else { - Ok(SpawnAgentResult::WithNickname { - task_name, - nickname, - }) - } + let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; + if hide_agent_metadata { + Ok(SpawnAgentResult::HiddenMetadata { task_name }) + } else { + Ok(SpawnAgentResult::WithNickname { + task_name, + nickname, + }) + } + }) } } From 64831d864ec4e74c3cb0009cc39fe2b95e6fe42f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 11 May 2026 23:34:09 -0700 Subject: [PATCH 2/2] permissions: move workspace roots onto thread state --- codex-rs/Cargo.lock | 3 + .../analytics/src/analytics_client_tests.rs | 2 + codex-rs/analytics/src/client_tests.rs | 15 +- .../schema/json/ClientRequest.json | 74 +-- .../codex_app_server_protocol.schemas.json | 113 +--- .../codex_app_server_protocol.v2.schemas.json | 113 +--- .../schema/json/v2/CommandExecParams.json | 7 - .../schema/json/v2/ThreadForkParams.json | 62 +-- .../schema/json/v2/ThreadForkResponse.json | 42 +- .../schema/json/v2/ThreadResumeParams.json | 62 +-- .../schema/json/v2/ThreadResumeResponse.json | 42 +- .../schema/json/v2/ThreadStartParams.json | 59 --- .../schema/json/v2/ThreadStartResponse.json | 42 +- .../schema/json/v2/TurnStartParams.json | 68 +-- .../typescript/v2/ActivePermissionProfile.ts | 8 +- .../v2/ActivePermissionProfileModification.ts | 6 - .../v2/PermissionProfileModificationParams.ts | 6 - .../v2/PermissionProfileSelectionParams.ts | 6 - .../schema/typescript/v2/SandboxPolicy.ts | 3 +- .../schema/typescript/v2/ThreadForkParams.ts | 7 +- .../typescript/v2/ThreadForkResponse.ts | 3 +- .../typescript/v2/ThreadResumeParams.ts | 7 +- .../typescript/v2/ThreadResumeResponse.ts | 3 +- .../typescript/v2/ThreadStartResponse.ts | 3 +- .../schema/typescript/v2/TurnStartParams.ts | 4 +- .../schema/typescript/v2/index.ts | 3 - .../src/protocol/common.rs | 2 + .../src/protocol/v2/permissions.rs | 130 ++--- .../src/protocol/v2/tests.rs | 50 +- .../src/protocol/v2/thread.rs | 79 ++- .../src/protocol/v2/turn.rs | 18 +- codex-rs/app-server/README.md | 26 +- .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 10 +- .../command_exec_processor.rs | 25 +- .../legacy_sandbox_compat.rs | 272 ++++++++++ .../request_processors/thread_lifecycle.rs | 5 +- .../request_processors/thread_processor.rs | 496 +++++++++++++++++- .../thread_processor_tests.rs | 97 ++++ .../src/request_processors/thread_summary.rs | 22 +- .../src/request_processors/turn_processor.rs | 132 +++-- codex-rs/app-server/tests/common/rollout.rs | 2 + .../tests/suite/conversation_summary.rs | 1 + .../app-server/tests/suite/v2/skills_list.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 118 +++++ .../tests/suite/v2/thread_unarchive.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 249 ++++++++- .../tests/suite/v2/turn_start_zsh_fork.rs | 11 +- codex-rs/cli/src/debug_sandbox.rs | 16 +- codex-rs/config/src/config_requirements.rs | 4 - codex-rs/config/src/config_toml.rs | 4 +- codex-rs/core/src/codex_thread.rs | 36 +- .../core/src/config/config_loader_tests.rs | 6 +- codex-rs/core/src/config/config_tests.rs | 103 ++-- codex-rs/core/src/config/mod.rs | 122 ++--- codex-rs/core/src/config/permissions.rs | 4 +- .../src/context/permissions_instructions.rs | 33 +- .../context/permissions_instructions_tests.rs | 42 +- .../sandbox_mode/workspace_write.md | 2 +- .../core/src/context_manager/history_tests.rs | 2 + codex-rs/core/src/context_manager/updates.rs | 11 +- codex-rs/core/src/exec.rs | 62 ++- codex-rs/core/src/exec_tests.rs | 62 ++- .../core/src/personality_migration_tests.rs | 1 + codex-rs/core/src/rollout.rs | 4 + codex-rs/core/src/safety_tests.rs | 21 +- codex-rs/core/src/session/handlers.rs | 4 + codex-rs/core/src/session/mod.rs | 7 +- codex-rs/core/src/session/review.rs | 1 + .../session/rollout_reconstruction_tests.rs | 16 + codex-rs/core/src/session/session.rs | 87 ++- codex-rs/core/src/session/tests.rs | 96 +++- codex-rs/core/src/session/turn_context.rs | 14 +- .../src/tools/handlers/apply_patch_tests.rs | 2 - .../src/tools/handlers/multi_agents_common.rs | 1 + codex-rs/core/src/tools/orchestrator.rs | 3 + .../core/src/tools/runtimes/apply_patch.rs | 1 + .../src/tools/runtimes/apply_patch_tests.rs | 2 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 6 +- codex-rs/core/src/tools/sandboxing.rs | 2 + codex-rs/core/tests/common/test_codex.rs | 2 + codex-rs/core/tests/suite/approvals.rs | 4 - codex-rs/core/tests/suite/exec.rs | 1 + .../core/tests/suite/permissions_messages.rs | 10 +- .../core/tests/suite/personality_migration.rs | 2 + codex-rs/core/tests/suite/prompt_caching.rs | 9 +- .../core/tests/suite/request_permissions.rs | 1 - codex-rs/core/tests/suite/resume_warning.rs | 2 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec/Cargo.toml | 1 + .../src/event_processor_with_human_output.rs | 90 +--- ...event_processor_with_human_output_tests.rs | 81 --- codex-rs/exec/src/lib.rs | 96 ++-- codex-rs/exec/src/lib_tests.rs | 164 +++++- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/exec/tests/suite/sandbox.rs | 20 +- codex-rs/file-system/src/lib.rs | 22 +- .../linux-sandbox/tests/suite/landlock.rs | 2 + codex-rs/mcp-server/src/outgoing_message.rs | 5 + codex-rs/memories/write/src/phase2.rs | 33 +- codex-rs/protocol/src/models.rs | 38 +- codex-rs/protocol/src/permissions.rs | 140 ++--- codex-rs/protocol/src/protocol.rs | 312 ++++++++++- codex-rs/rollout/Cargo.toml | 1 + codex-rs/rollout/src/config.rs | 17 + codex-rs/rollout/src/metadata_tests.rs | 3 + codex-rs/rollout/src/recorder.rs | 1 + codex-rs/rollout/src/recorder_tests.rs | 4 + codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 1 + codex-rs/sandboxing/src/manager.rs | 49 +- codex-rs/sandboxing/src/manager_tests.rs | 42 ++ codex-rs/sandboxing/src/seatbelt_tests.rs | 118 +++-- codex-rs/state/src/extract.rs | 8 + codex-rs/state/src/runtime/threads.rs | 2 + codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/thread-store/Cargo.toml | 1 + .../thread-store/src/local/create_thread.rs | 1 + .../thread-store/src/local/list_threads.rs | 1 + .../thread-store/src/local/live_writer.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 2 + .../src/local/update_thread_metadata.rs | 1 + codex-rs/thread-store/src/types.rs | 4 + codex-rs/tui/src/app/thread_routing.rs | 1 - codex-rs/tui/src/app_server_session.rs | 247 ++++----- .../tui/src/chatwidget/status_surfaces.rs | 4 +- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/permission_compat.rs | 95 ---- codex-rs/tui/src/status/card.rs | 88 ++-- codex-rs/tui/src/status/tests.rs | 131 +++-- codex-rs/utils/sandbox-summary/Cargo.toml | 2 +- .../sandbox-summary/src/config_summary.rs | 26 +- codex-rs/utils/sandbox-summary/src/lib.rs | 2 +- .../sandbox-summary/src/sandbox_summary.rs | 308 ++++++++--- codex-rs/windows-sandbox-rs/Cargo.toml | 2 +- codex-rs/windows-sandbox-rs/src/allow.rs | 78 +-- codex-rs/windows-sandbox-rs/src/audit.rs | 9 +- .../windows-sandbox-rs/src/elevated_impl.rs | 1 - codex-rs/windows-sandbox-rs/src/lib.rs | 18 +- codex-rs/windows-sandbox-rs/src/setup.rs | 54 +- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 1 - 145 files changed, 3576 insertions(+), 2080 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts create mode 100644 codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs delete mode 100644 codex-rs/tui/src/permission_compat.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bbb61f8337..731b83e9ae 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2697,6 +2697,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", + "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", @@ -3484,6 +3485,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-state", + "codex-utils-absolute-path", "codex-utils-path", "codex-utils-string", "pretty_assertions", @@ -3675,6 +3677,7 @@ dependencies = [ "codex-protocol", "codex-rollout", "codex-state", + "codex-utils-absolute-path", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5b4a72229b..be7332c8a5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -201,6 +201,7 @@ fn sample_thread_start_response( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -257,6 +258,7 @@ fn sample_thread_resume_response_with_source( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 71c46d808a..db182c6674 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -12,7 +12,6 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::SessionSource as AppServerSessionSource; @@ -29,7 +28,6 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use std::collections::HashSet; @@ -142,10 +140,6 @@ fn sample_thread(thread_id: &str) -> Thread { } } -fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::Disabled.into() -} - fn sample_thread_start_response() -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { thread: sample_thread("thread-1"), @@ -153,11 +147,12 @@ fn sample_thread_start_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), + permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) @@ -170,11 +165,12 @@ fn sample_thread_resume_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), + permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) @@ -187,11 +183,12 @@ fn sample_thread_fork_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), + permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6351993046..ebf0656cd7 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1850,31 +1850,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -1886,40 +1861,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -3133,13 +3074,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -3406,7 +3340,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -3817,7 +3752,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -4197,7 +4133,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4e061f8591..2a652da711 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5583,14 +5583,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/v2/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -5598,31 +5590,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -11710,31 +11677,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -11746,40 +11688,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/v2/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -14396,13 +14304,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -15390,7 +15291,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -15466,7 +15368,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ @@ -16893,7 +16795,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -16958,7 +16861,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ @@ -17266,7 +17169,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ @@ -17935,7 +17838,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c7130126f9..6c313ace91 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -143,14 +143,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -158,31 +150,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -8259,31 +8226,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -8295,40 +8237,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -10945,13 +10853,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -13214,7 +13115,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -13290,7 +13192,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ @@ -14717,7 +14619,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -14782,7 +14685,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ @@ -15090,7 +14993,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ @@ -15759,7 +15662,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index f29483862c..6a1098fda4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -441,13 +441,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 29d67403cd..3b3a81bb92 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -64,65 +64,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "SandboxMode": { "enum": [ "read-only", @@ -212,7 +153,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 6e74ab4ac8..c37dd2f46c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -1160,13 +1127,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -2605,7 +2565,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 5f07fe0149..69b12a365c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -298,65 +298,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -1091,7 +1032,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 727b7a3fb2..a896f72c67 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -1160,13 +1127,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -2605,7 +2565,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9a60049a61..99b25490ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,65 +90,6 @@ ], "type": "object" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index bf03f0fb55..6aeb6012b5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -1160,13 +1127,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -2605,7 +2565,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` for the exact effective permissions." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 1ef33d4301..7d8ba1e0b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -114,65 +114,6 @@ ], "type": "string" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -295,13 +236,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -576,7 +510,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts index cbc8c6ef0a..73f9efcab5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type ActivePermissionProfile = { /** @@ -13,9 +12,4 @@ id: string, * Parent profile identifier once permissions profiles support * inheritance. This is currently always `null`. */ -extends: string | null, -/** - * Bounded user-requested modifications applied on top of the named - * profile, if any. - */ -modifications: Array, }; +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts deleted file mode 100644 index 1cbee6868a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts deleted file mode 100644 index c619edcea8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts deleted file mode 100644 index a415bd0028..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; - -export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index 5575701ff2..1715d7710e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { NetworkAccess } from "./NetworkAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 6076a4bb14..167a6f1465 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -23,7 +23,12 @@ model?: string | null, modelProvider?: string | null, serviceTier?: string | nul * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** +approvalsReviewer?: ApprovalsReviewer | null, /** + * Deprecated for fork. When present, the server treats this as a + * compatibility spelling for selecting a matching named permissions + * profile. + */ +sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** * Optional client-supplied analytics source classification for this forked thread. */ threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c44533ec1a..cbf95b3971 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `permissionProfile` for the exact effective permissions. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 6d1dbdca4f..feae3906ba 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -25,4 +25,9 @@ model?: string | null, modelProvider?: string | null, serviceTier?: string | nul * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; +approvalsReviewer?: ApprovalsReviewer | null, /** + * Deprecated for resume. When present, the server treats this as a + * compatibility spelling for selecting a matching named permissions + * profile. + */ +sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f91756c7c6..4c784cabd2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `permissionProfile` for the exact effective permissions. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 9573bd7dee..05b5c260d8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `permissionProfile` for the exact effective permissions. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index b04919d86b..e6daf0b557 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -21,7 +21,9 @@ approvalPolicy?: AskForApproval | null, /** * subsequent turns. */ approvalsReviewer?: ApprovalsReviewer | null, /** - * Override the sandbox policy for this turn and subsequent turns. + * Deprecated for turns. When present, the server treats this as a + * compatibility spelling for selecting a matching named permissions + * profile. */ sandboxPolicy?: SandboxPolicy | null, /** * Override the model for this turn and subsequent turns. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index a6b961366e..de91837849 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; -export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; @@ -257,9 +256,7 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; -export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae00b08b73..5fcd3d024d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2272,6 +2272,7 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd, + workspace_roots: vec![], instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, @@ -2316,6 +2317,7 @@ mod tests { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), + "workspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], "approvalPolicy": "on-failure", "approvalsReviewer": "user", diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 86614a6aeb..919b2542cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -5,7 +5,6 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -437,41 +436,6 @@ pub struct ActivePermissionProfile { /// inheritance. This is currently always `null`. #[serde(default)] pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } } impl From for ActivePermissionProfile { @@ -479,11 +443,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -493,42 +452,10 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -607,14 +534,16 @@ pub enum SandboxPolicy { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, #[serde(default)] network_access: bool, #[serde(default)] exclude_tmpdir_env_var: bool, #[serde(default)] exclude_slash_tmp: bool, + #[serde(default, skip_serializing)] + #[schemars(skip)] + #[ts(skip)] + legacy_writable_roots: Vec, }, } @@ -690,10 +619,10 @@ impl<'de> Deserialize<'de> for SandboxPolicy { )); } Ok(SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, + legacy_writable_roots: writable_roots, }) } } @@ -720,18 +649,60 @@ impl SandboxPolicy { } } SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, + legacy_writable_roots: _, } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, }, } } + + pub fn legacy_writable_roots(&self) -> &[AbsolutePathBuf] { + match self { + SandboxPolicy::WorkspaceWrite { + legacy_writable_roots, + .. + } => legacy_writable_roots, + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::ExternalSandbox { .. } => &[], + } + } + + pub fn to_permission_profile_for_cwd(&self, cwd: &std::path::Path) -> CorePermissionProfile { + match self { + SandboxPolicy::WorkspaceWrite { + legacy_writable_roots, + .. + } if legacy_writable_roots.is_empty() => { + CorePermissionProfile::from_legacy_sandbox_policy_for_cwd(&self.to_core(), cwd) + } + SandboxPolicy::WorkspaceWrite { + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + legacy_writable_roots, + } => CorePermissionProfile::workspace_write_with( + legacy_writable_roots, + if *network_access { + CoreNetworkSandboxPolicy::Enabled + } else { + CoreNetworkSandboxPolicy::Restricted + }, + *exclude_tmpdir_env_var, + *exclude_slash_tmp, + ), + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::ExternalSandbox { .. } => { + CorePermissionProfile::from_legacy_sandbox_policy_for_cwd(&self.to_core(), cwd) + } + } + } } impl From for SandboxPolicy { @@ -752,15 +723,14 @@ impl From for SandboxPolicy { } } codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, + legacy_writable_roots: Vec::new(), }, } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 30599776ae..136a131530 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2019,17 +2019,16 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), }; let core_policy = v2_policy.to_core(); assert_eq!( core_policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2060,10 +2059,9 @@ fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { #[test] fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { - let writable_root = absolute_path("/workspace"); let policy = serde_json::from_value::(json!({ "type": "workspaceWrite", - "writableRoots": [writable_root], + "writableRoots": [], "readOnlyAccess": { "type": "fullAccess" }, @@ -2075,14 +2073,38 @@ fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { assert_eq!( policy, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path("/workspace")], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, + legacy_writable_roots: Vec::new(), } ); } +#[test] +fn sandbox_policy_deserializes_legacy_workspace_write_writable_roots_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + })) + .expect("workspace-write policy should accept legacy writableRoots field"); + assert_eq!(policy.legacy_writable_roots(), &[writable_root]); + + assert_eq!( + serde_json::to_value(policy).expect("policy should serialize"), + json!({ + "type": "workspaceWrite", + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }) + ); +} + #[test] fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { let err = serde_json::from_value::(json!({ @@ -3385,9 +3407,6 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); assert_eq!(fork.active_permission_profile, None); @@ -3415,6 +3434,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { responsesapi_client_metadata: None, environments: None, cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, @@ -3432,6 +3452,20 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn turn_start_permissions_uses_profile_id_string_shape() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "input": [], + "permissions": ":workspace" + })) + .expect("turn start params should deserialize"); + assert_eq!(params.permissions, Some(":workspace".to_string())); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized["permissions"], json!(":workspace")); +} + #[test] fn turn_start_params_round_trip_environments() { let cwd = test_absolute_path(); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 458722b3a2..ec39b353b0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -2,7 +2,6 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; use super::PermissionProfile; -use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; @@ -107,6 +106,11 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional workspace roots for this thread. Omitted uses the server's + /// configured roots, usually seeded from `cwd`. + #[experimental("thread/start.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -116,12 +120,11 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. + /// Named permissions profile id for this new thread's initial permissions. + /// Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -195,6 +198,11 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/start.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -203,11 +211,10 @@ pub struct ThreadStartResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `permissionProfile` for the exact effective permissions. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. + /// Exact effective permissions profile for this thread. This is + /// observational; clients cannot replace this value on existing threads. #[experimental("thread/start.permissionProfile")] #[serde(default)] pub permission_profile: Option, @@ -264,6 +271,11 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional replacement workspace roots for the resumed thread. Omitted + /// preserves the persisted or configured roots. + #[experimental("thread/resume.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -271,14 +283,16 @@ pub struct ThreadResumeParams { /// and subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// Deprecated for resume. When present, the server treats this as a + /// compatibility spelling for selecting a matching named permissions + /// profile. #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named permissions profile id for the resumed thread. Cannot be combined + /// with `sandbox`. #[experimental("thread/resume.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -310,6 +324,11 @@ pub struct ThreadResumeResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/resume.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -318,11 +337,10 @@ pub struct ThreadResumeResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `permissionProfile` for the exact effective permissions. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. + /// Exact effective permissions profile for this thread. This is + /// observational; clients cannot replace this value on existing threads. #[experimental("thread/resume.permissionProfile")] #[serde(default)] pub permission_profile: Option, @@ -370,6 +388,11 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional replacement workspace roots for the forked thread. Omitted + /// preserves the source thread roots when available. + #[experimental("thread/fork.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -377,14 +400,16 @@ pub struct ThreadForkParams { /// and subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// Deprecated for fork. When present, the server treats this as a + /// compatibility spelling for selecting a matching named permissions + /// profile. #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named permissions profile id for the forked thread. Cannot be combined + /// with `sandbox`. #[experimental("thread/fork.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -419,6 +444,11 @@ pub struct ThreadForkResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/fork.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -427,11 +457,10 @@ pub struct ThreadForkResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `permissionProfile` for the exact effective permissions. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. + /// Exact effective permissions profile for this thread. This is + /// observational; clients cannot replace this value on existing threads. #[experimental("thread/fork.permissionProfile")] #[serde(default)] pub permission_profile: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 61a09bfbf5..7dff5363c9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -1,6 +1,5 @@ use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfileSelectionParams; use super::SandboxPolicy; use super::Turn; use codex_experimental_api_macros::ExperimentalApi; @@ -64,6 +63,11 @@ pub struct TurnStartParams { /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the workspace roots for this turn and subsequent turns. Omitted + /// preserves the current roots. + #[experimental("turn/start.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -72,16 +76,16 @@ pub struct TurnStartParams { /// subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, - /// Override the sandbox policy for this turn and subsequent turns. + /// Deprecated for turns. When present, the server treats this as a + /// compatibility spelling for selecting a matching named permissions + /// profile. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. + /// Select a named permissions profile id for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c167627257..18a0390115 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -67,7 +67,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. @@ -122,9 +122,9 @@ 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/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/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 accepted as a compatibility spelling only when it maps to a named permissions profile and cannot be combined with `permissions`. +- `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`, active profile name updates, or legacy `sandbox` compatibility selection. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known; the full `PermissionProfile` value is not exposed by thread lifecycle responses. - `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. @@ -147,7 +147,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 replace the thread's permission profile with arbitrary values. The legacy `sandboxPolicy` field is accepted as a compatibility spelling only when it is a no-op or maps to a named permissions profile. 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"`. @@ -222,10 +222,11 @@ 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: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) @@ -259,7 +260,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. Older clients may still send `sandbox` when it maps to the same named profile selection. 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. @@ -627,19 +628,14 @@ 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: - // "permissions": { "type": "profile", "id": ":workspace" } - // Do not send both "sandboxPolicy" and "permissions". + // Optional: select a named permission profile for this and later turns. + // "permissions": ":workspace" "model": "gpt-5.1-codex", "effort": "medium", "summary": "concise", diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 516e042301..5e243dcdd9 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -658,6 +658,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, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f24dcaa34f..a0b73e369d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,8 +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; use codex_app_server_protocol::PluginInstallResponse; @@ -354,6 +352,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; @@ -431,6 +431,11 @@ use uuid::Uuid; #[cfg(test)] use codex_app_server_protocol::ServerRequest; +struct ResolvedPermissionProfileSelection { + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, +} + mod account_processor; mod apps_processor; mod catalog_processor; @@ -442,6 +447,7 @@ mod feedback_processor; mod fs_processor; mod git_processor; mod initialize_processor; +mod legacy_sandbox_compat; mod marketplace_processor; mod mcp_processor; mod plugins; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627..3ebc1b3928 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -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() @@ -247,21 +248,13 @@ impl CommandExecRequestProcessor { .can_set(&effective_permission_profile) .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; effective_permission_profile - } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + } else if let Some(policy) = sandbox_policy { + let legacy_policy = policy.to_core(); self.config .permissions - .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) + .can_set_legacy_sandbox_policy(&legacy_policy, &sandbox_cwd) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); - let network_sandbox_policy = - codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); - let permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); + let permission_profile = policy.to_permission_profile_for_cwd(&sandbox_cwd); self.config .permissions .permission_profile @@ -283,10 +276,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, ) diff --git a/codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs b/codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs new file mode 100644 index 0000000000..409d28583f --- /dev/null +++ b/codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs @@ -0,0 +1,272 @@ +use super::*; + +const WORKSPACE_PERMISSION_PROFILE_ID: &str = ":workspace"; +const READ_ONLY_PERMISSION_PROFILE_ID: &str = ":read-only"; +const DANGER_NO_SANDBOX_PERMISSION_PROFILE_ID: &str = ":danger-no-sandbox"; + +pub(super) struct CurrentPermissionProfile<'a> { + pub(super) permission_profile: &'a PermissionProfile, + pub(super) workspace_roots: &'a [AbsolutePathBuf], +} + +pub(super) struct LegacySandboxProfileSelection { + pub(super) permissions: String, + pub(super) workspace_roots: Option>, + expected_enforcement: codex_protocol::models::SandboxEnforcement, + expected_network: codex_protocol::permissions::NetworkSandboxPolicy, +} + +pub(super) enum LegacySandboxResolution { + Noop { + workspace_roots: Option>, + }, + Selection(LegacySandboxProfileSelection), +} + +pub(super) fn resolve_legacy_sandbox_profile_selection( + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, + current: Option>, + cwd: &AbsolutePathBuf, + explicit_workspace_roots: Option<&[AbsolutePathBuf]>, + field_name: &str, +) -> Result { + let legacy_workspace_roots = + workspace_roots_from_implicit_legacy_sandbox(cwd, sandbox_policy, explicit_workspace_roots); + if let Some(current_match) = legacy_sandbox_current_match( + sandbox_policy, + current, + cwd, + explicit_workspace_roots, + legacy_workspace_roots.as_deref(), + ) { + return Ok(LegacySandboxResolution::Noop { + workspace_roots: current_match.workspace_roots, + }); + } + + let expected_network = + codex_protocol::permissions::NetworkSandboxPolicy::from(&sandbox_policy.to_core()); + match sandbox_policy { + codex_app_server_protocol::SandboxPolicy::DangerFullAccess => Ok( + LegacySandboxResolution::Selection(LegacySandboxProfileSelection { + permissions: DANGER_NO_SANDBOX_PERMISSION_PROFILE_ID.to_string(), + workspace_roots: None, + expected_enforcement: codex_protocol::models::SandboxEnforcement::Disabled, + expected_network, + }), + ), + codex_app_server_protocol::SandboxPolicy::ReadOnly { network_access: _ } => Ok( + LegacySandboxResolution::Selection(LegacySandboxProfileSelection { + permissions: READ_ONLY_PERMISSION_PROFILE_ID.to_string(), + workspace_roots: None, + expected_enforcement: codex_protocol::models::SandboxEnforcement::Managed, + expected_network, + }), + ), + codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { .. } => Ok( + LegacySandboxResolution::Selection(LegacySandboxProfileSelection { + permissions: WORKSPACE_PERMISSION_PROFILE_ID.to_string(), + workspace_roots: legacy_workspace_roots, + expected_enforcement: codex_protocol::models::SandboxEnforcement::Managed, + expected_network, + }), + ), + codex_app_server_protocol::SandboxPolicy::ExternalSandbox { .. } => { + Err(invalid_request(format!( + "`{field_name}` externalSandbox cannot be mapped to a named permissions profile" + ))) + } + } +} + +pub(super) fn sandbox_policy_from_legacy_mode( + sandbox_mode: SandboxMode, +) -> codex_app_server_protocol::SandboxPolicy { + match sandbox_mode { + SandboxMode::ReadOnly => codex_protocol::protocol::SandboxPolicy::new_read_only_policy(), + SandboxMode::WorkspaceWrite => { + codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy() + } + SandboxMode::DangerFullAccess => codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + } + .into() +} + +pub(super) fn validate_legacy_sandbox_profile_selection( + legacy_selection: &LegacySandboxProfileSelection, + resolved_selection: &ResolvedPermissionProfileSelection, + field_name: &str, +) -> Result<(), JSONRPCErrorError> { + let permission_profile = &resolved_selection.permission_profile; + if permission_profile.enforcement() != legacy_selection.expected_enforcement { + return Err(invalid_request(format!( + "`{field_name}` does not match permissions profile `{}`", + legacy_selection.permissions + ))); + } + if permission_profile.network_sandbox_policy() != legacy_selection.expected_network { + return Err(invalid_request(format!( + "`{field_name}` network access does not match permissions profile `{}`", + legacy_selection.permissions + ))); + } + Ok(()) +} + +pub(super) fn resolve_cwd_against_fallback( + cwd: Option<&Path>, + fallback_cwd: &AbsolutePathBuf, +) -> AbsolutePathBuf { + match cwd { + Some(cwd) => { + if let Ok(path) = AbsolutePathBuf::try_from(cwd) { + path + } else { + AbsolutePathBuf::resolve_path_against_base(cwd, fallback_cwd.as_path()) + } + } + None => fallback_cwd.clone(), + } +} + +struct LegacySandboxCurrentMatch { + workspace_roots: Option>, +} + +fn legacy_sandbox_current_match( + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, + current: Option>, + cwd: &AbsolutePathBuf, + explicit_workspace_roots: Option<&[AbsolutePathBuf]>, + legacy_workspace_roots: Option<&[AbsolutePathBuf]>, +) -> Option { + let current = current?; + + let projection_workspace_roots = explicit_workspace_roots + .or(legacy_workspace_roots) + .unwrap_or(current.workspace_roots); + let materialized_profile = current + .permission_profile + .materialize_project_roots_with_workspace_roots(projection_workspace_roots); + let file_system_policy = materialized_profile.file_system_sandbox_policy(); + let active_sandbox = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &materialized_profile, + &file_system_policy, + materialized_profile.network_sandbox_policy(), + cwd.as_path(), + ); + if active_sandbox != sandbox_policy.to_core() { + return None; + } + + let workspace_roots = legacy_workspace_roots + .filter(|roots| *roots != current.workspace_roots) + .map(<[AbsolutePathBuf]>::to_vec); + + Some(LegacySandboxCurrentMatch { workspace_roots }) +} + +fn workspace_roots_from_implicit_legacy_sandbox( + cwd: &AbsolutePathBuf, + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, + explicit_workspace_roots: Option<&[AbsolutePathBuf]>, +) -> Option> { + if explicit_workspace_roots.is_some() + || !matches!( + sandbox_policy, + codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { .. } + ) + { + None + } else { + Some(workspace_roots_from_legacy_sandbox(cwd, sandbox_policy)) + } +} + +fn workspace_roots_from_legacy_sandbox( + cwd: &AbsolutePathBuf, + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, +) -> Vec { + let mut roots = Vec::with_capacity(1 + sandbox_policy.legacy_writable_roots().len()); + push_unique_root(&mut roots, cwd.clone()); + for root in sandbox_policy.legacy_writable_roots() { + push_unique_root(&mut roots, root.clone()); + } + roots +} + +fn push_unique_root(roots: &mut Vec, root: AbsolutePathBuf) { + if !roots.iter().any(|existing| existing == &root) { + roots.push(root); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn abs_test_path(name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join(name)) + .expect("temp dir path should be absolute") + } + + fn workspace_write_policy() -> codex_app_server_protocol::SandboxPolicy { + codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), + } + } + + #[test] + fn legacy_workspace_sandbox_updates_roots_when_current_profile_matches_new_cwd() { + let old_root = abs_test_path("codex-old-workspace-root"); + let cwd = abs_test_path("codex-new-workspace-root"); + let policy = workspace_write_policy(); + + let resolution = resolve_legacy_sandbox_profile_selection( + &policy, + Some(CurrentPermissionProfile { + permission_profile: &PermissionProfile::workspace_write(), + workspace_roots: std::slice::from_ref(&old_root), + }), + &cwd, + /*explicit_workspace_roots*/ None, + "sandboxPolicy", + ) + .expect("legacy sandbox should resolve"); + + match resolution { + LegacySandboxResolution::Noop { + workspace_roots: Some(workspace_roots), + } => assert_eq!(workspace_roots, vec![cwd]), + _ => panic!("expected workspace-roots-only resolution, got unexpected selection"), + } + } + + #[test] + fn legacy_workspace_sandbox_is_noop_when_current_workspace_roots_match() { + let cwd = abs_test_path("codex-current-workspace-root"); + let policy = workspace_write_policy(); + + let resolution = resolve_legacy_sandbox_profile_selection( + &policy, + Some(CurrentPermissionProfile { + permission_profile: &PermissionProfile::workspace_write(), + workspace_roots: std::slice::from_ref(&cwd), + }), + &cwd, + /*explicit_workspace_roots*/ None, + "sandboxPolicy", + ) + .expect("legacy sandbox should resolve"); + + match resolution { + LegacySandboxResolution::Noop { + workspace_roots: None, + } => {} + _ => panic!("expected no-op resolution, got unexpected workspace roots or selection"), + } + } +} diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 7dab206d85..9c67f25433 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -604,11 +604,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); let session_id = conversation.session_configured().session_id.to_string(); @@ -625,6 +627,7 @@ pub(super) async fn handle_pending_thread_resume_request( approvals_reviewer: approvals_reviewer.into(), sandbox, permission_profile: Some(permission_profile.into()), + workspace_roots, active_permission_profile, reasoning_effort, }; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 9b5cef069d..6e26373077 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1,3 +1,4 @@ +use super::legacy_sandbox_compat::*; use super::*; use crate::error_code::method_not_found; @@ -17,6 +18,119 @@ struct ThreadListFilters { use_state_db_only: bool, } +#[derive(Clone)] +struct PersistedThreadPermissionState { + permission_profile: PermissionProfile, + active_permission_profile: Option, + workspace_roots: Vec, +} + +fn absolute_path_from_history_path( + path: &Path, + base: Option<&AbsolutePathBuf>, +) -> Option { + 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, + cwd: Option<&AbsolutePathBuf>, +) -> Vec { + if roots.is_empty() { + cwd.cloned().into_iter().collect() + } else { + roots + } +} + +fn effective_cwd_for_legacy_sandbox( + request_cwd: Option<&str>, + history_cwd: Option<&Path>, + persisted_permission_state: Option<&PersistedThreadPermissionState>, +) -> Option { + let history_cwd = + history_cwd.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None)); + request_cwd + .and_then(|cwd| absolute_path_from_history_path(Path::new(cwd), history_cwd.as_ref())) + .or_else(|| history_cwd.clone()) + .or_else(|| { + persisted_permission_state + .and_then(|state| state.workspace_roots.first()) + .cloned() + }) +} + +fn persisted_thread_permission_state( + history: &InitialHistory, + fallback_cwd: Option<&Path>, + fallback_sandbox_policy: Option<&codex_protocol::protocol::SandboxPolicy>, +) -> Option { + 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(context.workspace_roots); + 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, + ) + })); + if context.active_permission_profile.is_some() { + active_permission_profile = context.active_permission_profile; + } + } + RolloutItem::EventMsg(EventMsg::SessionConfigured(event)) => { + cwd = Some(event.cwd.clone()); + workspace_roots = Some(event.workspace_roots); + 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: workspace_roots.unwrap_or_else(|| cwd.into_iter().collect()), + }) +} + fn collect_resume_override_mismatches( request: &ThreadResumeParams, config_snapshot: &ThreadConfigSnapshot, @@ -98,12 +212,6 @@ fn collect_resume_override_mismatches( )); } } - if request.permissions.is_some() { - mismatch_details.push(format!( - "permissions override was provided and ignored while running; active={:?}", - config_snapshot.active_permission_profile - )); - } if let Some(requested_personality) = request.personality.as_ref() && config_snapshot.personality.as_ref() != Some(requested_personality) { @@ -802,6 +910,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -835,6 +944,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -1160,6 +1270,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 = @@ -1176,6 +1287,7 @@ impl ThreadRequestProcessor { approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, permission_profile: Some(config_snapshot.permission_profile.into()), + workspace_roots: config_snapshot.workspace_roots, active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; @@ -1212,10 +1324,11 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - permissions: Option, + permissions: Option, base_instructions: Option, developer_instructions: Option, personality: Option, @@ -1225,6 +1338,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 @@ -1241,6 +1356,47 @@ impl ThreadRequestProcessor { overrides } + async fn validate_active_permission_profile_selection( + &self, + permissions: String, + request_overrides: Option>, + cwd: Option, + fallback_cwd: Option, + ) -> Result { + 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}" + ))); + } + let active_permission_profile = + config + .permissions + .active_permission_profile() + .ok_or_else(|| { + invalid_request( + "permission profile selection did not resolve to a named profile", + ) + })?; + Ok(ResolvedPermissionProfileSelection { + permission_profile: config.permissions.permission_profile(), + active_permission_profile, + }) + } + fn parse_environment_selections( &self, environments: Option>, @@ -2381,6 +2537,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2393,6 +2550,7 @@ impl ThreadRequestProcessor { persist_extended_history: _persist_extended_history, } = params; let include_turns = !exclude_turns; + let mut workspace_roots = workspace_roots; let (thread_history, resume_source_thread) = match if let Some(history) = history { self.resume_thread_from_history(history.as_slice()) @@ -2411,19 +2569,144 @@ 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 permission_profile_selection = 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(selection) => Some(selection), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else if let Some(sandbox_mode) = sandbox { + let sandbox_policy = sandbox_policy_from_legacy_mode(sandbox_mode); + let Some(effective_cwd) = effective_cwd_for_legacy_sandbox( + cwd.as_deref(), + history_cwd.as_deref(), + persisted_permission_state.as_ref(), + ) else { + self.outgoing + .send_error( + request_id, + invalid_request("`sandbox` requires a cwd to resolve legacy permissions"), + ) + .await; + return Ok(()); + }; + match resolve_legacy_sandbox_profile_selection( + &sandbox_policy, + persisted_permission_state + .as_ref() + .map(|state| CurrentPermissionProfile { + permission_profile: &state.permission_profile, + workspace_roots: &state.workspace_roots, + }), + &effective_cwd, + workspace_roots.as_deref(), + "sandbox", + ) { + Ok(LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + }) => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + None + } + Ok(LegacySandboxResolution::Selection(legacy_selection)) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + match self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await + .and_then(|selection| { + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandbox", + ) + .map(|()| selection) + }) { + Ok(selection) => Some(selection), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else { + None + }; + let active_permission_profile = permission_profile_selection + .as_ref() + .map(|selection| selection.active_permission_profile.clone()) + .or_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(selection) = permission_profile_selection { + typesafe_overrides.permission_profile = Some(selection.permission_profile); + } else if let Some(persisted_permission_state) = persisted_permission_state.as_ref() { + typesafe_overrides.permission_profile = + Some(persisted_permission_state.permission_profile.clone()); + } + if !workspace_roots_were_explicit { + if let Some(persisted_permission_state) = persisted_permission_state.as_ref() { + typesafe_overrides.workspace_roots = Some( + persisted_permission_state + .workspace_roots + .iter() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .collect(), + ); + } else if 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, @@ -2432,7 +2715,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 @@ -2444,6 +2727,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(); @@ -2537,6 +2821,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( @@ -2558,6 +2843,7 @@ impl ThreadRequestProcessor { approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, permission_profile: Some(config_snapshot.permission_profile.into()), + workspace_roots: config_snapshot.workspace_roots, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -2711,6 +2997,94 @@ impl ThreadRequestProcessor { ) .await?; + let config_snapshot = existing_thread.config_snapshot().await; + let mut workspace_roots = params.workspace_roots.clone(); + let permission_profile_selection = if let Some(permissions) = params.permissions.clone() + { + Some( + self.validate_active_permission_profile_selection( + permissions, + /*request_overrides*/ None, + /*cwd*/ None, + Some(config_snapshot.cwd.to_path_buf()), + ) + .await?, + ) + } else if let Some(sandbox_mode) = params.sandbox { + let sandbox_policy = sandbox_policy_from_legacy_mode(sandbox_mode); + match resolve_legacy_sandbox_profile_selection( + &sandbox_policy, + Some(CurrentPermissionProfile { + permission_profile: &config_snapshot.permission_profile, + workspace_roots: &config_snapshot.workspace_roots, + }), + &config_snapshot.cwd, + workspace_roots.as_deref(), + "sandbox", + )? { + LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + } => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + None + } + LegacySandboxResolution::Selection(legacy_selection) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + let selection = self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + /*request_overrides*/ None, + /*cwd*/ None, + Some(config_snapshot.cwd.to_path_buf()), + ) + .await?; + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandbox", + )?; + Some(selection) + } + } + } else { + None + }; + if workspace_roots.is_some() || permission_profile_selection.is_some() { + existing_thread + .update_turn_context_overrides(CodexThreadTurnContextOverrides { + cwd: None, + workspace_roots: workspace_roots.map(|roots| { + roots + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .collect() + }), + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permission_profile: permission_profile_selection + .as_ref() + .map(|selection| selection.permission_profile.clone()), + active_permission_profile: permission_profile_selection + .map(|selection| selection.active_permission_profile), + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await + .map_err(|err| { + invalid_request(format!("invalid thread resume override: {err}")) + })?; + } + let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); if !mismatch_details.is_empty() { @@ -3017,6 +3391,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3077,26 +3452,119 @@ 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 mut workspace_roots = workspace_roots; + let permission_profile_selection = 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 if let Some(sandbox_mode) = sandbox { + let sandbox_policy = sandbox_policy_from_legacy_mode(sandbox_mode); + let effective_cwd = effective_cwd_for_legacy_sandbox( + cwd.as_deref(), + history_cwd.as_deref(), + Some(&persisted_permission_state), + ) + .ok_or_else(|| { + invalid_request("`sandbox` requires a cwd to resolve legacy permissions") + })?; + match resolve_legacy_sandbox_profile_selection( + &sandbox_policy, + Some(CurrentPermissionProfile { + permission_profile: &persisted_permission_state.permission_profile, + workspace_roots: &persisted_permission_state.workspace_roots, + }), + &effective_cwd, + workspace_roots.as_deref(), + "sandbox", + )? { + LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + } => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + None + } + LegacySandboxResolution::Selection(legacy_selection) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + let selection = self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await?; + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandbox", + )?; + Some(selection) + } + } + } else { + None + }; + let active_permission_profile = permission_profile_selection + .as_ref() + .map(|selection| selection.active_permission_profile.clone()) + .or_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(if let Some(selection) = permission_profile_selection { + selection.permission_profile + } else { + 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; @@ -3200,6 +3668,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 = @@ -3216,6 +3685,7 @@ impl ThreadRequestProcessor { approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, permission_profile: Some(config_snapshot.permission_profile.into()), + workspace_roots: config_snapshot.workspace_roots, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 61a31f9c4d..0ebb428e39 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -495,6 +495,101 @@ mod thread_processor_behavior_tests { )); } + #[test] + fn persisted_thread_permission_state_uses_latest_turn_active_profile() { + let cwd = test_path_buf("/tmp/project").abs(); + let workspace_root = test_path_buf("/tmp/workspace").abs(); + let active_permission_profile = + codex_protocol::models::ActivePermissionProfile::new(":workspace"); + let permission_profile = codex_protocol::models::PermissionProfile::workspace_write(); + + let history = codex_protocol::protocol::InitialHistory::Forked(vec![ + codex_protocol::protocol::RolloutItem::TurnContext( + codex_protocol::protocol::TurnContextItem { + turn_id: Some("turn-1".to_string()), + trace_id: None, + cwd: cwd.to_path_buf(), + workspace_roots: vec![workspace_root.clone()], + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(permission_profile.clone()), + active_permission_profile: Some(active_permission_profile.clone()), + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }, + ), + ]); + + let persisted = persisted_thread_permission_state( + &history, + Some(cwd.as_path()), + /*fallback_sandbox_policy*/ None, + ) + .expect("permission state should be reconstructed"); + + assert_eq!(persisted.permission_profile, permission_profile); + assert_eq!( + persisted.active_permission_profile, + Some(active_permission_profile) + ); + assert_eq!(persisted.workspace_roots, vec![workspace_root]); + } + + #[test] + fn persisted_thread_permission_state_preserves_empty_workspace_roots_from_event_roundtrip() { + let cwd = test_path_buf("/tmp/project").abs(); + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + let permission_profile = codex_protocol::models::PermissionProfile::workspace_write(); + let event = codex_protocol::protocol::EventMsg::SessionConfigured( + codex_protocol::protocol::SessionConfiguredEvent { + session_id: thread_id.into(), + thread_id, + forked_from_id: None, + thread_source: None, + thread_name: None, + model: "gpt-5".to_string(), + model_provider_id: "mock_provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, + permission_profile: permission_profile.clone(), + active_permission_profile: None, + cwd: cwd.clone(), + workspace_roots: Vec::new(), + reasoning_effort: None, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }, + ); + let event = serde_json::from_value(serde_json::to_value(event).unwrap()).unwrap(); + let history = codex_protocol::protocol::InitialHistory::Forked(vec![ + codex_protocol::protocol::RolloutItem::EventMsg(event), + ]); + + let persisted = persisted_thread_permission_state( + &history, + Some(cwd.as_path()), + /*fallback_sandbox_policy*/ None, + ) + .expect("permission state should be reconstructed"); + + assert_eq!(persisted.permission_profile, permission_profile); + assert_eq!(persisted.workspace_roots, Vec::::new()); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( @@ -630,6 +725,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some("priority".to_string())), cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -650,6 +746,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, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 875bd3deaf..d6bb2f8c48 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -177,32 +177,26 @@ pub(super) fn thread_response_active_permission_profile( pub(super) fn apply_permission_profile_selection_to_config_overrides( overrides: &mut ConfigOverrides, - permissions: Option, + permissions: Option, ) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + let Some(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() diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d1dae4ef46..b71aef3767 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,3 +1,4 @@ +use super::legacy_sandbox_compat::*; use super::*; #[derive(Clone)] @@ -357,6 +358,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() @@ -375,47 +377,70 @@ impl TurnRequestProcessor { } let cwd = params.cwd; + let mut 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 legacy_sandbox_policy = params.sandbox_policy; + let sandbox_policy = None; 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, + let selection = self + .validate_active_permission_profile_selection( + permissions, + cwd.clone(), 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}" - ))); - } + .await?; ( - Some(config.permissions.permission_profile()), - config.permissions.active_permission_profile(), + Some(selection.permission_profile), + Some(selection.active_permission_profile), ) + } else if let Some(legacy_sandbox_policy) = legacy_sandbox_policy.as_ref() { + let snapshot = thread.config_snapshot().await; + let effective_cwd = resolve_cwd_against_fallback(cwd.as_deref(), &snapshot.cwd); + match resolve_legacy_sandbox_profile_selection( + legacy_sandbox_policy, + Some(CurrentPermissionProfile { + permission_profile: &snapshot.permission_profile, + workspace_roots: &snapshot.workspace_roots, + }), + &effective_cwd, + workspace_roots.as_deref(), + "sandboxPolicy", + )? { + LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + } => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + (None, None) + } + LegacySandboxResolution::Selection(legacy_selection) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + let selection = self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + cwd.clone(), + Some(snapshot.cwd.to_path_buf()), + ) + .await?; + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandboxPolicy", + )?; + ( + Some(selection.permission_profile), + Some(selection.active_permission_profile), + ) + } + } } else { (None, None) }; @@ -432,6 +457,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(), @@ -457,6 +485,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, @@ -516,6 +545,49 @@ impl TurnRequestProcessor { Ok(TurnStartResponse { turn }) } + async fn validate_active_permission_profile_selection( + &self, + permissions: String, + cwd: Option, + fallback_cwd: Option, + ) -> Result { + 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*/ None, overrides, fallback_cwd) + .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}" + ))); + } + let active_permission_profile = + config + .permissions + .active_permission_profile() + .ok_or_else(|| { + invalid_request( + "permission profile selection did not resolve to a named profile", + ) + })?; + Ok(ResolvedPermissionProfileSelection { + permission_profile: config.permissions.permission_profile(), + active_permission_profile, + }) + } + async fn thread_inject_items_response_inner( &self, params: ThreadInjectItemsParams, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6b2a9a0abe..6c113297d7 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -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, @@ -219,6 +220,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, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 754d1f9467..e692ed95f2 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -132,6 +132,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + workspace_roots: Vec::new(), }, event_persistence_mode: ThreadEventPersistenceMode::default(), }) diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515ad..9254019d85 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -618,6 +618,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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 80254d8f47..c8872c47bf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -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, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 52420c0c80..c1fca44a44 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1288,6 +1288,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, }, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 64dfe0beb7..3ce6df7bf7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; @@ -1474,6 +1475,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, @@ -2235,6 +2237,122 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R Ok(()) } +#[tokio::test] +async fn thread_resume_running_applies_workspace_roots_and_active_profile_name() -> Result<()> { + let server = responses::start_mock_server().await; + let first_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ])); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); + let _response_mock = + responses::mount_response_sequence(&server, vec![first_response, second_response]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace_root = codex_home.path().join("replacement-root"); + std::fs::create_dir_all(&workspace_root)?; + let workspace_root = AbsolutePathBuf::from_absolute_path(workspace_root.canonicalize()?)?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + workspace_roots: Some(vec![workspace_root.clone()]), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + workspace_roots, + active_permission_profile, + .. + } = to_response::(resume_resp)?; + assert_eq!(workspace_roots, vec![workspace_root]); + assert_eq!( + active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()), + Some(":workspace") + ); + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 5b421dcec5..b851af545c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -218,6 +218,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + workspace_roots: Vec::new(), }, event_persistence_mode: ThreadEventPersistenceMode::default(), }) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 524b795b81..51ade13b6c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -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; @@ -291,7 +290,9 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( write_test_skill(codex_home.path(), "alpha-skill")?; write_test_skill(codex_home.path(), "beta-skill")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -779,10 +780,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some(PermissionProfileSelectionParams::Profile { - id: ":danger-no-sandbox".to_string(), - modifications: None, - }), + permissions: Some(":danger-no-sandbox".to_string()), ..Default::default() }) .await?; @@ -812,6 +810,87 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() Ok(()) } +#[tokio::test] +async fn turn_start_rejects_unknown_permission_selection_before_starting_turn() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + let config_toml = codex_home.path().join("config.toml"); + let config_contents = std::fs::read_to_string(&config_toml)?; + let config_contents = config_contents.replace( + "model_provider = \"mock_provider\"\n\n[features]", + r#"model_provider = "mock_provider" + +default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.filesystem.":project_roots"] +"." = "write" + +[features] +"#, + ); + std::fs::write(config_toml, config_contents)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + permissions: Some("missing-profile".to_string()), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert!( + err.error + .message + .contains("default_permissions refers to undefined profile `missing-profile`"), + "unexpected error message: {}", + err.error.message + ); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected permissions selection" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1657,7 +1736,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), @@ -1825,7 +1903,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()?; @@ -1854,12 +1932,14 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { )?, create_final_assistant_message_sse_response("done second")?, ]; - let server = create_mock_responses_server_sequence(responses).await; - create_config_toml( + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence(&server, responses).await; + create_config_toml_with_sandbox( &codex_home, &server.uri(), "untrusted", &BTreeMap::default(), + "read-only", )?; let mut mcp = McpProcess::new(&codex_home).await?; @@ -1879,7 +1959,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(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, @@ -1890,15 +1970,11 @@ 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.clone().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, - }), - permissions: None, + sandbox_policy: None, + permissions: Some(":workspace".to_string()), model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1920,7 +1996,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, @@ -1931,9 +2008,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), @@ -1981,6 +2059,22 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { assert_eq!(command, expected_command); assert_eq!(status, CommandExecutionStatus::InProgress); + let requests = response_mock.requests(); + assert!( + requests.len() >= 3, + "expected at least 3 model requests, got {}", + requests.len() + ); + let second_turn_developer_text = requests[2].message_input_texts("developer").join("\n"); + let first_cwd_name = first_cwd + .file_name() + .expect("first cwd should have a final path component") + .to_string_lossy(); + assert!( + second_turn_developer_text.contains(first_cwd_name.as_ref()), + "second turn developer instructions should retain first_cwd as a workspace root; got {second_turn_developer_text:?}" + ); + timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), @@ -1990,6 +2084,110 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_legacy_workspace_sandbox_updates_workspace_roots_for_cwd() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let first_cwd = tmp.path().join("turn1"); + let second_cwd = tmp.path().join("turn2"); + std::fs::create_dir(&first_cwd)?; + std::fs::create_dir(&second_cwd)?; + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + create_final_assistant_message_sse_response("done first")?, + create_final_assistant_message_sse_response("done second")?, + ], + ) + .await; + create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; + + 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::(start_resp)?; + + let first_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(first_cwd.clone()), + workspace_roots: Some(vec![first_cwd.clone().try_into()?]), + permissions: Some(":workspace".to_string()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(second_cwd.clone()), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), + }), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let second_turn_developer_text = requests[1].message_input_texts("developer").join("\n"); + let second_cwd_name = second_cwd + .file_name() + .expect("second cwd should have a final path component") + .to_string_lossy(); + assert!( + second_turn_developer_text.contains(second_cwd_name.as_ref()), + "legacy sandboxPolicy should rebind workspace roots to second_cwd; got {second_turn_developer_text:?}" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> { let tmp = TempDir::new()?; @@ -3306,7 +3504,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?; @@ -3402,7 +3599,8 @@ 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_accepts_legacy_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; @@ -3436,7 +3634,12 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R .send_turn_start_request(TurnStartParams { thread_id: thread.id, cwd: Some(workspace.path().to_path_buf()), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), + }), input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 31247418e5..c20eb58883 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -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), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 2722f69849..81e95dcdfc 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -769,6 +769,16 @@ mod tests { Ok(()) } + fn workspace_write_policy_for_codex_home( + codex_home: &TempDir, + ) -> codex_protocol::permissions::FileSystemSandboxPolicy { + let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")) + .expect("codex home tempdir should be absolute"); + codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy() + .with_additional_legacy_workspace_writable_roots(std::slice::from_ref(&memories_root)) + } + #[tokio::test] async fn debug_sandbox_honors_active_permission_profiles() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -947,8 +957,7 @@ mod tests { assert_eq!( config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + workspace_write_policy_for_codex_home(&codex_home) ); Ok(()) @@ -980,8 +989,7 @@ mod tests { assert_eq!( config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + workspace_write_policy_for_codex_home(&codex_home) ); Ok(()) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 8bf3148ba8..2dccad1fca 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -2144,7 +2144,6 @@ allowed_approvals_reviewers = ["user"] let config: ConfigRequirementsToml = from_str(toml_str)?; let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; assert!( requirements .permission_profile @@ -2154,7 +2153,6 @@ allowed_approvals_reviewers = ["user"] .is_ok() ); let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2265,9 +2263,7 @@ allowed_approvals_reviewers = ["user"] ); let requirements = ConfigRequirements::try_from(requirements_with_sources)?; - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 989aab1691..0285f85349 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -757,7 +757,7 @@ impl ConfigToml { SandboxMode::ReadOnly => PermissionProfile::read_only(), SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -768,7 +768,7 @@ impl ConfigToml { NetworkSandboxPolicy::Restricted }; PermissionProfile::workspace_write_with( - writable_roots, + &[], network_policy, *exclude_tmpdir_env_var, *exclude_slash_tmp, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index c110d30f66..0680429237 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -59,6 +59,7 @@ pub struct ThreadConfigSnapshot { pub permission_profile: PermissionProfile, pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, pub personality: Option, @@ -68,11 +69,15 @@ pub struct ThreadConfigSnapshot { impl ThreadConfigSnapshot { pub fn sandbox_policy(&self) -> SandboxPolicy { - let file_system_sandbox_policy = self.permission_profile.file_system_sandbox_policy(); + let permission_profile = self + .permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - &self.permission_profile, + &permission_profile, &file_system_sandbox_policy, - self.permission_profile.network_sandbox_policy(), + permission_profile.network_sandbox_policy(), self.cwd.as_path(), ) } @@ -82,6 +87,7 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadTurnContextOverrides { pub cwd: Option, + pub workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_policy: Option, @@ -241,8 +247,26 @@ impl CodexThread { &self, overrides: CodexThreadTurnContextOverrides, ) -> ConstraintResult<()> { + let updates = self.turn_context_updates_from_overrides(overrides).await; + self.codex.session.validate_settings(&updates).await + } + + /// Apply persistent thread context overrides immediately. + pub async fn update_turn_context_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> ConstraintResult<()> { + let updates = self.turn_context_updates_from_overrides(overrides).await; + self.codex.session.update_settings(updates).await + } + + async fn turn_context_updates_from_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> SessionSettingsUpdate { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -266,8 +290,9 @@ impl CodexThread { .with_updates(model, effort, /*developer_instructions*/ None) }; - let updates = SessionSettingsUpdate { + SessionSettingsUpdate { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -279,8 +304,7 @@ impl CodexThread { service_tier, personality, ..Default::default() - }; - self.codex.session.validate_settings(&updates).await + } } /// Use sparingly: this is intended to be removed soon. diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 6296d13886..a3ae9a466f 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -527,9 +527,10 @@ writable_roots = ["~/code"] let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?; match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { assert_eq!( - writable_roots + config + .workspace_roots .iter() .filter(|root| **root == expected_root) .count(), @@ -593,7 +594,6 @@ allowed_sandbox_modes = ["read-only"] .permission_profile .can_set(&PermissionProfile::from_legacy_sandbox_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8ff8df4e83..69ba436d4d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -67,7 +67,6 @@ use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; @@ -99,6 +98,14 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; +fn materialized_file_system_sandbox_policy(config: &Config) -> FileSystemSandboxPolicy { + config + .permissions + .permission_profile() + .materialize_project_roots_with_workspace_roots(&config.workspace_roots) + .file_system_sandbox_policy() +} + fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -1361,7 +1368,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: }, FileSystemSandboxEntry { path: FileSystemPath::Path { - path: memories_root.clone(), + path: memories_root, }, access: FileSystemAccessMode::Write, }, @@ -1370,7 +1377,6 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1547,7 +1553,6 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1748,7 +1753,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config .permissions @@ -1769,7 +1774,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl } #[tokio::test] -async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() +async fn default_permissions_read_only_records_additional_writable_roots_as_workspace_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -1790,18 +1795,18 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); - assert!( - policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), - "expected additional writable root to modify :read-only, policy: {policy:?}" + let policy = materialized_file_system_sandbox_policy(&config); + assert_eq!( + policy, + PermissionProfile::read_only().file_system_sandbox_policy() + ); + assert_eq!( + config.workspace_roots, + vec![cwd.path().abs(), extra_root.clone()] ); assert_eq!( config.permissions.active_permission_profile(), - Some( - ActivePermissionProfile::new(":read-only").with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { path: extra_root }, - ]) - ) + Some(ActivePermissionProfile::new(":read-only")) ); Ok(()) } @@ -1832,7 +1837,7 @@ async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_setti ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted @@ -1872,7 +1877,7 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config .permissions @@ -1940,7 +1945,7 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert!( policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), "expected implicit :workspace to preserve sandbox_workspace_write.writable_roots, policy: {policy:?}" @@ -1957,12 +1962,11 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se ); match config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => { - assert!(writable_roots.contains(&extra_root)); + assert!(config.workspace_roots.contains(&extra_root)); assert!(network_access); assert!(exclude_tmpdir_env_var); assert!(!exclude_slash_tmp); @@ -2006,7 +2010,7 @@ async fn implicit_builtin_workspace_profile_preserves_add_dir_metadata_carveouts ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); let extra_root = extra_root.path().abs(); assert!( policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), @@ -2181,9 +2185,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() ) .await?; - let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( - codex_home.path().join("memories"), - )?)?; assert!( config .permissions @@ -2193,7 +2194,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![external_write_path, memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2803,7 +2803,6 @@ trust_level = "trusted" assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root.clone()], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2843,7 +2842,6 @@ exclude_slash_tmp = true assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2907,13 +2905,21 @@ exclude_slash_tmp = true NetworkSandboxPolicy::from(&sandbox_policy), "case `{name}` should preserve network semantics from legacy config" ); - assert_eq!( - file_system_policy - .to_legacy_sandbox_policy(network_policy, cwd.path()) - .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), - sandbox_policy, - "case `{name}` should preserve its legacy compatibility projection" - ); + let direct_legacy_projection = + file_system_policy.to_legacy_sandbox_policy(network_policy, cwd.path()); + if name == "workspace-write" && !cfg!(target_os = "windows") { + assert!( + direct_legacy_projection.is_err(), + "case `{name}` should require the compatibility projection for split workspace roots" + ); + } else { + assert_eq!( + direct_legacy_projection + .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), + sandbox_policy, + "case `{name}` should preserve its legacy compatibility projection" + ); + } match name.as_str() { "danger-full-access" | "read-only" => { @@ -2955,14 +2961,19 @@ exclude_slash_tmp = true }) ); assert!( - file_system_policy + config.workspace_roots.contains(&extra_root), + "case `{name}` should store legacy writable roots as thread workspace roots" + ); + assert!( + materialized_file_system_sandbox_policy(&config) .entries .contains(&FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra_root.clone(), }, access: FileSystemAccessMode::Write, - }) + }), + "case `{name}` should materialize workspace roots into runtime write access" ); for subpath in [".git", ".agents", ".codex"] { assert!( @@ -3705,14 +3716,15 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< } } else { match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { assert_eq!( - writable_roots + config + .workspace_roots .iter() .filter(|root| **root == expected_backend) .count(), 1, - "expected single writable root entry for {}", + "expected single workspace root entry for {}", expected_backend.display() ); } @@ -3772,13 +3784,16 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result "expected memories root directory to exist at {}", memories_root.display() ); - let expected_memories_root = memories_root.abs(); + let expected_memories_root = + AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(&memories_root)?)?; match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { + let writable_roots = materialized_file_system_sandbox_policy(&config) + .get_writable_roots_with_cwd(config.cwd.as_path()); assert_eq!( writable_roots .iter() - .filter(|root| **root == expected_memories_root) + .filter(|root| root.root == expected_memories_root) .count(), 1, "expected single writable root entry for {}", @@ -7226,6 +7241,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7670,6 +7686,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7828,6 +7845,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7971,6 +7989,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c65b713a9e..4ebe7b76fa 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -88,7 +88,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -569,6 +568,10 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute roots that define the writable project/workspace set for + /// symbolic `:project_roots` permission entries. + pub workspace_roots: Vec, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -1850,6 +1853,11 @@ fn apply_managed_filesystem_constraints( } } +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -1878,6 +1886,9 @@ pub struct ConfigOverrides { pub ephemeral: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Explicit workspace roots for this session. When set, this is the full + /// root list rather than an additive override. + pub workspace_roots: Option>, } /// Resolves the OSS provider from CLI override, profile config, or global config. @@ -2159,6 +2170,7 @@ impl Config { tools_web_search_request: override_tools_web_search_request, ephemeral, additional_writable_roots, + workspace_roots: workspace_roots_override, } = overrides; if sandbox_mode.is_some() && permission_profile.is_some() { @@ -2247,11 +2259,10 @@ impl Config { } } }))?; - let mut additional_writable_roots: Vec = additional_writable_roots + let requested_additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); - let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -2293,12 +2304,7 @@ impl Config { }; let memories_root = memory_root(&codex_home); std::fs::create_dir_all(&memories_root)?; - if !additional_writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - additional_writable_roots.push(memories_root); - } + let internal_writable_roots = vec![memories_root]; let profiles_are_active = default_permissions_override.is_some() || matches!( @@ -2308,6 +2314,31 @@ impl Config { || permission_config_syntax.is_none(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); + let should_seed_legacy_workspace_roots = + default_permissions.is_none() + && matches!( + permission_config_syntax, + None | Some(PermissionConfigSyntax::Legacy) + ); + let mut workspace_roots = match workspace_roots_override { + Some(workspace_roots) => workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) + }) + .collect(), + None => { + let mut workspace_roots = vec![resolved_cwd.clone()]; + workspace_roots.extend(requested_additional_writable_roots.clone()); + if should_seed_legacy_workspace_roots + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + workspace_roots + } + }; + dedupe_absolute_paths(&mut workspace_roots); let ( mut configured_network_proxy_config, permission_profile, @@ -2344,10 +2375,7 @@ impl Config { ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2398,28 +2426,8 @@ impl Config { resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = if using_implicit_builtin_profile { - file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots( - &additional_writable_roots, - ) - } else { - file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ) - }; - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } else if matches!(permission_profile, PermissionProfile::Managed { .. }) - && !requested_additional_writable_roots.is_empty() - { - file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &requested_additional_writable_roots, - ); + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, network_sandbox_policy, @@ -2436,22 +2444,7 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - let active_permission_profile = if !requested_additional_writable_roots.is_empty() - && matches!(permission_profile, PermissionProfile::Managed { .. }) - { - ActivePermissionProfile::new(default_permissions).with_modifications( - requested_additional_writable_roots - .iter() - .cloned() - .map(|path| { - ActivePermissionProfileModification::AdditionalWritableRoot { path } - }) - .collect(), - ) - } else { - ActivePermissionProfile::new(default_permissions) - }; - Some(active_permission_profile) + Some(ActivePermissionProfile::new(default_permissions)) }; ( configured_network_proxy_config, @@ -2490,25 +2483,25 @@ impl Config { } let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - // `additional_writable_roots` is a legacy workspace-write knob. It - // only applies when the derived managed profile has workspace-style - // write access to the project roots; read-only, disabled, external, - // and future non-workspace profiles must not silently grow extra - // write access. + // Internal writable roots only apply when the derived managed + // profile has workspace-style write access to the project roots; + // read-only, disabled, external, and future non-workspace profiles + // must not silently grow extra write access. + let materialized_file_system_sandbox_policy = permission_profile + .materialize_project_roots_with_workspace_roots(&workspace_roots) + .file_system_sandbox_policy(); if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && file_system_sandbox_policy.can_write_path_with_cwd( + && materialized_file_system_sandbox_policy.can_write_path_with_cwd( resolved_cwd.as_path(), resolved_cwd.as_path(), ) - && !file_system_sandbox_policy.has_full_disk_write_access() + && !materialized_file_system_sandbox_policy.has_full_disk_write_access() { - // Keep legacy behavior for extra writable roots while storing - // the result as the canonical permission profile. Explicit - // extra roots are concrete paths, so their metadata carveouts - // are also concrete rather than symbolic `:project_roots` - // entries. + // Keep Codex runtime write access while storing the result as + // the canonical permission profile. Workspace roots themselves + // are held separately on the thread. file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -3002,6 +2995,7 @@ impl Config { model_provider_id, model_provider, cwd: resolved_cwd, + workspace_roots, startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 6b6021ad34..eb4da0d447 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -68,12 +68,12 @@ pub(crate) fn builtin_permission_profile( BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, }) => PermissionProfile::workspace_write_with( - writable_roots, + &[], if *network_access { NetworkSandboxPolicy::Enabled } else { diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 0ccd6c33a7..4f30c644c0 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -30,6 +30,8 @@ const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = const SANDBOX_MODE_WORKSPACE_WRITE: &str = include_str!("prompts/permissions/sandbox_mode/workspace_write.md"); const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md"); +const MAX_WRITABLE_ROOTS_IN_PROMPT: usize = 8; +const MAX_WRITABLE_ROOT_LABEL_CHARS: usize = 160; static SANDBOX_MODE_DANGER_FULL_ACCESS_TEMPLATE: LazyLock