Merge branch 'main' into rhan/oob-turn-metadata

This commit is contained in:
rhan-oai
2026-03-24 14:21:48 -07:00
committed by GitHub
276 changed files with 14389 additions and 8868 deletions
+237 -26
View File
@@ -5,8 +5,10 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::context_manager::is_user_turn_boundary;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::event_mapping::parse_turn_item;
use crate::find_archived_thread_path_by_id_str;
use crate::find_thread_path_by_id_str;
use crate::rollout::RolloutRecorder;
@@ -18,7 +20,10 @@ use crate::thread_manager::ThreadManagerState;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
@@ -29,6 +34,7 @@ use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use serde::Serialize;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::sync::Arc;
@@ -51,6 +57,13 @@ pub(crate) struct LiveAgent {
pub(crate) status: AgentStatus,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub(crate) struct ListedAgent {
pub(crate) agent_name: String,
pub(crate) agent_status: AgentStatus,
pub(crate) last_task_message: Option<String>,
}
fn default_agent_nickname_list() -> Vec<&'static str> {
AGENT_NAMES
.lines()
@@ -266,6 +279,7 @@ impl AgentControl {
new_thread.thread_id,
notification_source,
child_reference,
agent_metadata.agent_path.clone(),
);
Ok(LiveAgent {
@@ -437,6 +451,7 @@ impl AgentControl {
resumed_thread.thread_id,
Some(notification_source.clone()),
child_reference,
agent_metadata.agent_path.clone(),
);
self.persist_thread_spawn_edge_for_source(
resumed_thread.thread.as_ref(),
@@ -454,21 +469,28 @@ impl AgentControl {
agent_id: ThreadId,
items: Vec<UserInput>,
) -> CodexResult<String> {
let last_task_message = render_input_preview(&items);
let state = self.upgrade()?;
self.handle_thread_request_result(
agent_id,
&state,
state
.send_op(
agent_id,
Op::UserInput {
items,
final_output_json_schema: None,
},
)
.await,
)
.await
let result = self
.handle_thread_request_result(
agent_id,
&state,
state
.send_op(
agent_id,
Op::UserInput {
items,
final_output_json_schema: None,
},
)
.await,
)
.await;
if result.is_ok() {
self.state
.update_last_task_message(agent_id, last_task_message);
}
result
}
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
@@ -492,15 +514,22 @@ impl AgentControl {
agent_id: ThreadId,
communication: InterAgentCommunication,
) -> CodexResult<String> {
let last_task_message = communication.content.clone();
let state = self.upgrade()?;
self.handle_thread_request_result(
agent_id,
&state,
state
.send_op(agent_id, Op::InterAgentCommunication { communication })
.await,
)
.await
let result = self
.handle_thread_request_result(
agent_id,
&state,
state
.send_op(agent_id, Op::InterAgentCommunication { communication })
.await,
)
.await;
if result.is_ok() {
self.state
.update_last_task_message(agent_id, last_task_message);
}
result
}
/// Interrupt the current task for an existing agent thread.
@@ -678,6 +707,70 @@ impl AgentControl {
.join("\n")
}
pub(crate) async fn list_agents(
&self,
current_session_source: &SessionSource,
path_prefix: Option<&str>,
) -> CodexResult<Vec<ListedAgent>> {
let state = self.upgrade()?;
let resolved_prefix = path_prefix
.map(|prefix| {
current_session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root)
.resolve(prefix)
.map_err(CodexErr::UnsupportedOperation)
})
.transpose()?;
let mut live_agents = self.state.live_agents();
live_agents.sort_by(|left, right| {
left.agent_path
.as_deref()
.unwrap_or_default()
.cmp(right.agent_path.as_deref().unwrap_or_default())
.then_with(|| {
left.agent_id
.map(|id| id.to_string())
.unwrap_or_default()
.cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default())
})
});
let mut agents = Vec::with_capacity(live_agents.len());
for metadata in live_agents {
let Some(thread_id) = metadata.agent_id else {
continue;
};
if resolved_prefix
.as_ref()
.is_some_and(|prefix| !agent_matches_prefix(metadata.agent_path.as_ref(), prefix))
{
continue;
}
let Ok(thread) = state.get_thread(thread_id).await else {
continue;
};
let agent_name = metadata
.agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| thread_id.to_string());
let last_task_message = match metadata.last_task_message.clone() {
Some(last_task_message) => Some(last_task_message),
None => last_task_message_for_thread(thread.as_ref()).await,
};
agents.push(ListedAgent {
agent_name,
agent_status: thread.agent_status().await,
last_task_message,
});
}
Ok(agents)
}
/// Starts a detached watcher for sub-agents spawned from another thread.
///
/// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and
@@ -687,6 +780,7 @@ impl AgentControl {
child_thread_id: ThreadId,
session_source: Option<SessionSource>,
child_reference: String,
child_agent_path: Option<AgentPath>,
) {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
@@ -717,14 +811,41 @@ impl AgentControl {
let Ok(state) = control.upgrade() else {
return;
};
let child_thread = state.get_thread(child_thread_id).await.ok();
let message = format_subagent_notification_message(child_reference.as_str(), &status);
if child_agent_path.is_some()
&& child_thread
.as_ref()
.map(|thread| thread.enabled(Feature::MultiAgentV2))
.unwrap_or(true)
{
let Some(child_agent_path) = child_agent_path.clone() else {
return;
};
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| AgentPath::try_from(parent).ok())
else {
return;
};
let communication = InterAgentCommunication::new(
child_agent_path,
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
let _ = control
.send_inter_agent_communication(parent_thread_id, communication)
.await;
return;
}
let Ok(parent_thread) = state.get_thread(parent_thread_id).await else {
return;
};
parent_thread
.inject_user_message_without_turn(format_subagent_notification_message(
child_reference.as_str(),
&status,
))
.inject_user_message_without_turn(message)
.await;
});
}
@@ -764,6 +885,7 @@ impl AgentControl {
agent_path,
agent_nickname,
agent_role,
last_task_message: None,
};
Ok((session_source, agent_metadata))
}
@@ -927,6 +1049,95 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option<Threa
}
}
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
if prefix.is_root() {
return true;
}
agent_path.is_some_and(|agent_path| {
agent_path == prefix
|| agent_path
.as_str()
.strip_prefix(prefix.as_str())
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option<String> {
let pending_input = thread.codex.session.pending_input_snapshot().await;
if let Some(message) = pending_input
.iter()
.rev()
.find_map(last_task_message_from_input_item)
{
return Some(message);
}
let queued_input = thread
.codex
.session
.queued_response_items_for_next_turn_snapshot()
.await;
if let Some(message) = queued_input
.iter()
.rev()
.find_map(last_task_message_from_input_item)
{
return Some(message);
}
let history = thread.codex.session.clone_history().await;
history
.raw_items()
.iter()
.rev()
.find_map(last_task_message_from_item)
}
fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option<String> {
let response_item: ResponseItem = item.clone().into();
last_task_message_from_item(&response_item)
}
fn last_task_message_from_item(item: &ResponseItem) -> Option<String> {
if !is_user_turn_boundary(item) {
return None;
}
match item {
ResponseItem::Message { role, .. } if role == "user" => {
let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else {
return None;
};
Some(render_input_preview(&message.content))
}
ResponseItem::Message { content, .. } => match content.as_slice() {
[ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.map(|communication| communication.content)
}
_ => None,
},
_ => None,
}
}
fn render_input_preview(items: &[UserInput]) -> String {
items
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth),
+285
View File
@@ -11,11 +11,13 @@ use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
use assert_matches::assert_matches;
use chrono::Utc;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnAbortReason;
@@ -125,6 +127,29 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool {
})
}
fn history_contains_assistant_inter_agent_communication(
history_items: &[ResponseItem],
expected: &InterAgentCommunication,
) -> bool {
history_items.iter().any(|item| {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "assistant" {
return false;
}
content.iter().any(|content_item| match content_item {
ContentItem::OutputText { text } => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.as_ref()
== Some(expected)
}
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
})
})
}
async fn wait_for_subagent_notification(parent_thread: &Arc<CodexThread>) -> bool {
let wait = async {
loop {
@@ -382,6 +407,67 @@ async fn send_input_submits_user_message() {
assert_eq!(captured, Some(expected));
}
#[tokio::test]
async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() {
let harness = AgentControlHarness::new().await;
let (thread_id, thread) = harness.start_thread().await;
let communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"hello from tests".to_string(),
false,
);
let submission_id = harness
.control
.send_inter_agent_communication(thread_id, communication.clone())
.await
.expect("send_inter_agent_communication should succeed");
assert!(!submission_id.is_empty());
let expected = (
thread_id,
Op::InterAgentCommunication {
communication: communication.clone(),
},
);
let captured = harness
.manager
.captured_ops()
.into_iter()
.find(|entry| *entry == expected);
assert_eq!(captured, Some(expected));
timeout(Duration::from_secs(5), async {
loop {
if thread
.codex
.session
.has_queued_response_items_for_next_turn()
.await
{
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("inter-agent communication should stay pending");
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(!history_contains_assistant_inter_agent_communication(
&history_items,
&communication
));
}
#[tokio::test]
async fn append_message_records_assistant_message() {
let harness = AgentControlHarness::new().await;
@@ -937,6 +1023,204 @@ async fn spawn_child_completion_notifies_parent_history() {
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
}
#[tokio::test]
async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
let harness = AgentControlHarness::new().await;
let (root_thread_id, root_thread) = harness.start_thread().await;
let mut config = harness.config.clone();
let _ = config.features.enable(Feature::MultiAgentV2);
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let worker_thread_id = harness
.control
.spawn_agent(
config.clone(),
text_input("hello worker"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root_thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("worker spawn should succeed");
let tester_path = worker_path.join("tester").expect("tester path");
let tester_thread_id = harness
.control
.spawn_agent(
config,
text_input("hello tester"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("tester spawn should succeed");
harness
.control
.shutdown_live_agent(worker_thread_id)
.await
.expect("worker shutdown should succeed");
let tester_thread = harness
.manager
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let tester_turn = tester_thread.codex.session.new_default_turn().await;
tester_thread
.codex
.session
.send_event(
tester_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
}),
)
.await;
sleep(Duration::from_millis(100)).await;
assert!(
!harness
.manager
.captured_ops()
.into_iter()
.any(|(thread_id, op)| {
thread_id == worker_thread_id
&& matches!(
op,
Op::InterAgentCommunication { communication }
if communication.author == tester_path
&& communication.recipient == worker_path
&& communication.content == "done"
)
})
);
let root_history_items = root_thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(!history_contains_assistant_inter_agent_communication(
&root_history_items,
&InterAgentCommunication::new(
tester_path,
AgentPath::root(),
Vec::new(),
"done".to_string(),
true,
)
));
assert!(!has_subagent_notification(&root_history_items));
}
#[tokio::test]
async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
let harness = AgentControlHarness::new().await;
let (_root_thread_id, root_thread) = harness.start_thread().await;
let (worker_thread_id, _worker_thread) = harness.start_thread().await;
let mut tester_config = harness.config.clone();
let _ = tester_config.features.enable(Feature::MultiAgentV2);
let tester_thread_id = harness
.manager
.start_thread(tester_config)
.await
.expect("tester thread should start")
.thread_id;
let tester_thread = harness
.manager
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let tester_path = worker_path.join("tester").expect("tester path");
harness.control.maybe_start_completion_watcher(
tester_thread_id,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
tester_path.to_string(),
Some(tester_path.clone()),
);
let tester_turn = tester_thread.codex.session.new_default_turn().await;
tester_thread
.codex
.session
.send_event(
tester_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
}),
)
.await;
let expected_message = crate::session_prefix::format_subagent_notification_message(
tester_path.as_str(),
&AgentStatus::Completed(Some("done".to_string())),
);
let expected = (
worker_thread_id,
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
tester_path.clone(),
worker_path.clone(),
Vec::new(),
expected_message.clone(),
false,
),
},
);
timeout(Duration::from_secs(5), async {
loop {
let captured = harness
.manager
.captured_ops()
.into_iter()
.find(|entry| *entry == expected);
if captured == Some(expected.clone()) {
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("completion watcher should queue a direct-parent message");
let root_history_items = root_thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(!history_contains_assistant_inter_agent_communication(
&root_history_items,
&InterAgentCommunication::new(
tester_path,
AgentPath::root(),
Vec::new(),
expected_message,
false,
)
));
}
#[tokio::test]
async fn completion_watcher_notifies_parent_when_child_is_missing() {
let harness = AgentControlHarness::new().await;
@@ -953,6 +1237,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
agent_role: Some("explorer".to_string()),
})),
child_thread_id.to_string(),
None,
);
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
+29
View File
@@ -38,6 +38,7 @@ pub(crate) struct AgentMetadata {
pub(crate) agent_path: Option<AgentPath>,
pub(crate) agent_nickname: Option<String>,
pub(crate) agent_role: Option<String>,
pub(crate) last_task_message: Option<String>,
}
fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String {
@@ -151,6 +152,34 @@ impl AgentRegistry {
.cloned()
}
pub(crate) fn live_agents(&self) -> Vec<AgentMetadata> {
self.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.agent_tree
.values()
.filter(|metadata| {
metadata.agent_id.is_some()
&& !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root)
})
.cloned()
.collect()
}
pub(crate) fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(metadata) = active_agents
.agent_tree
.values_mut()
.find(|metadata| metadata.agent_id == Some(thread_id))
{
metadata.last_task_message = Some(last_task_message);
}
}
fn register_spawned_thread(&self, agent_metadata: AgentMetadata) {
let Some(thread_id) = agent_metadata.agent_id else {
return;
+2 -2
View File
@@ -1,9 +1,9 @@
use crate::AuthManager;
use crate::config::Config;
use crate::default_client::create_client;
use crate::git_info::collect_git_info;
use crate::git_info::get_git_repo_root;
use crate::plugins::PluginTelemetryMetadata;
use codex_git_utils::collect_git_info;
use codex_git_utils::get_git_repo_root;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
+39 -22
View File
@@ -236,9 +236,6 @@ pub(crate) struct PreviousTurnSettings {
use crate::exec_policy::ExecPolicyUpdateError;
use crate::feedback_tags;
use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherEvent;
use crate::git_info::get_git_repo_root;
use crate::guardian::GuardianReviewSessionManager;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::inspect_pending_input;
@@ -323,6 +320,8 @@ use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
use crate::skills::injection::tool_kind_for_path;
use crate::skills::resolve_skill_dependencies_for_turn;
use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
use crate::state::SessionState;
@@ -351,6 +350,7 @@ use crate::unified_exec::UnifiedExecProcessManager;
use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_git_utils::get_git_repo_root;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_otel::metrics::names::THREAD_STARTED_METRIC;
@@ -404,7 +404,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) file_watcher: Arc<FileWatcher>,
pub(crate) skills_watcher: Arc<SkillsWatcher>,
pub(crate) conversation_history: InitialHistory,
pub(crate) session_source: SessionSource,
pub(crate) agent_control: AgentControl,
@@ -457,7 +457,7 @@ impl Codex {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
conversation_history,
session_source,
agent_control,
@@ -645,7 +645,7 @@ impl Codex {
skills_manager,
plugins_manager,
mcp_manager.clone(),
file_watcher,
skills_watcher,
agent_control,
)
.await
@@ -1298,13 +1298,13 @@ impl Session {
self.out_of_band_elicitation_paused.send_replace(paused);
}
fn start_file_watcher_listener(self: &Arc<Self>) {
let mut rx = self.services.file_watcher.subscribe();
fn start_skills_watcher_listener(self: &Arc<Self>) {
let mut rx = self.services.skills_watcher.subscribe();
let weak_sess = Arc::downgrade(self);
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(FileWatcherEvent::SkillsChanged { .. }) => {
Ok(SkillsWatcherEvent::SkillsChanged { .. }) => {
let Some(sess) = weak_sess.upgrade() else {
break;
};
@@ -1440,7 +1440,7 @@ impl Session {
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
skills_watcher: Arc<SkillsWatcher>,
agent_control: AgentControl,
) -> anyhow::Result<Arc<Self>> {
debug!(
@@ -1859,7 +1859,7 @@ impl Session {
skills_manager,
plugins_manager: Arc::clone(&plugins_manager),
mcp_manager: Arc::clone(&mcp_manager),
file_watcher,
skills_watcher,
agent_control,
network_proxy,
network_approval: Arc::clone(&network_approval),
@@ -1938,7 +1938,7 @@ impl Session {
}
// Start the watcher after SessionConfigured so it cannot emit earlier events.
sess.start_file_watcher_listener();
sess.start_skills_watcher_listener();
// Construct sandbox_state before MCP startup so it can be sent to each
// MCP server immediately after it becomes ready (avoiding blocking).
let sandbox_state = SandboxState {
@@ -3957,6 +3957,17 @@ impl Session {
}
}
pub(crate) async fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
let active = self.active_turn.lock().await;
match active.as_ref() {
Some(at) => {
let ts = at.turn_state.lock().await;
ts.pending_input_snapshot()
}
None => Vec::with_capacity(0),
}
}
/// Queue response items to be injected into the next active turn created for this session.
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
if items.is_empty() {
@@ -3971,6 +3982,12 @@ impl Session {
std::mem::take(&mut *self.idle_pending_input.lock().await)
}
pub(crate) async fn queued_response_items_for_next_turn_snapshot(
&self,
) -> Vec<ResponseInputItem> {
self.idle_pending_input.lock().await.clone()
}
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
!self.idle_pending_input.lock().await.is_empty()
}
@@ -4634,27 +4651,27 @@ mod handlers {
}
}
/// Records an inter-agent assistant envelope and, when requested, wakes the recipient by
/// starting a regular turn if the session is currently idle.
pub async fn inter_agent_communication(
sess: &Arc<Session>,
sub_id: String,
communication: InterAgentCommunication,
) {
let pending_item = communication.to_response_input_item();
if sess
.inject_response_items(vec![pending_item.clone()])
.await
.is_ok()
{
if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await {
return;
}
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
sess.queue_response_items_for_next_turn(vec![pending_item])
.await;
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
.await;
if communication.trigger_turn {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
.await;
}
}
pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command: String) {
@@ -36,19 +36,18 @@ fn assistant_message(text: &str) -> ResponseItem {
}
fn inter_agent_assistant_message(text: &str) -> ResponseItem {
let communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root().join("worker").unwrap(),
Vec::new(),
text.to_string(),
true,
);
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: serde_json::to_string(&InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root()
.join("worker")
.expect("worker path should be valid"),
Vec::new(),
text.to_string(),
))
.expect("inter-agent communication should serialize"),
text: serde_json::to_string(&communication).unwrap(),
}],
end_turn: None,
phase: None,
+1 -1
View File
@@ -80,7 +80,7 @@ pub(crate) async fn run_codex_thread_interactive(
skills_manager: Arc::clone(&parent_session.services.skills_manager),
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
file_watcher: Arc::clone(&parent_session.services.file_watcher),
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
conversation_history: initial_history.unwrap_or(InitialHistory::New),
session_source: SessionSource::SubAgent(subagent_source),
agent_control: parent_session.services.agent_control.clone(),
+35 -5
View File
@@ -38,6 +38,7 @@ use crate::protocol::NetworkApprovalProtocol;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::RateLimitWindow;
use crate::protocol::ResumedHistory;
use crate::protocol::RolloutItem;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
@@ -1166,6 +1167,35 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
})
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// The parent rollout writer drains asynchronously after turn completion.
// Wait until the persisted JSONL includes the source user turn before forking from it.
let mut source_history_persisted = false;
for _ in 0..100 {
let history = RolloutRecorder::get_rollout_history(&rollout_path).await;
source_history_persisted = history.ok().is_some_and(|history| {
history.get_rollout_items().into_iter().any(|item| {
matches!(
item,
RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. })
if role == "user"
&& content.iter().any(|content_item| {
matches!(
content_item,
ContentItem::InputText { text } if text == "fork seed"
)
})
)
})
});
if source_history_persisted {
break;
}
sleep(StdDuration::from_millis(10)).await;
}
assert!(
source_history_persisted,
"source rollout should contain the completed pre-fork user turn before forking"
);
let mut fork_config = initial.config.clone();
fork_config.permissions.approval_policy =
@@ -2494,7 +2524,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(FileWatcher::noop()),
Arc::new(SkillsWatcher::noop()),
AgentControl::default(),
)
.await;
@@ -2593,7 +2623,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
.expect("create environment"),
);
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = Arc::new(SkillsWatcher::noop());
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
McpConnectionManager::new_mcp_connection_manager_for_tests(
@@ -2627,7 +2657,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
@@ -3428,7 +3458,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
.expect("create environment"),
);
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = Arc::new(SkillsWatcher::noop());
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
McpConnectionManager::new_mcp_connection_manager_for_tests(
@@ -3462,7 +3492,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
+2 -2
View File
@@ -435,7 +435,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
true,
));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = Arc::new(SkillsWatcher::noop());
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
config,
@@ -444,7 +444,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
conversation_history: InitialHistory::New,
session_source: SessionSource::SubAgent(SubAgentSource::Other(
GUARDIAN_REVIEWER_NAME.to_string(),
+8 -1
View File
@@ -5639,8 +5639,15 @@ fn write_fake_bwrap(contents: &str) -> tempfile::TempPath {
use std::os::unix::fs::PermissionsExt;
use tempfile::NamedTempFile;
// Bazel can mount the OS temp directory `noexec`, so prefer the current
// working directory for fake executables and fall back to the default temp
// dir outside that environment.
let temp_file = std::env::current_dir()
.ok()
.and_then(|dir| NamedTempFile::new_in(dir).ok())
.unwrap_or_else(|| NamedTempFile::new().expect("temp file"));
// Linux rejects exec-ing a file that is still open for writing.
let path = NamedTempFile::new().expect("temp file").into_temp_path();
let path = temp_file.into_temp_path();
fs::write(&path, contents).expect("write fake bwrap");
let permissions = fs::Permissions::from_mode(0o755);
fs::set_permissions(&path, permissions).expect("chmod fake bwrap");
+3 -3
View File
@@ -39,7 +39,6 @@ use crate::config_loader::McpServerRequirement;
use crate::config_loader::ResidencyRequirement;
use crate::config_loader::Sourced;
use crate::config_loader::load_config_layers_state;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::memories::memory_root;
use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
@@ -66,6 +65,7 @@ use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::Features;
use codex_features::FeaturesToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
@@ -134,7 +134,7 @@ pub use service::ConfigService;
pub use service::ConfigServiceError;
pub use types::ApprovalsReviewer;
pub use codex_git::GhostSnapshotConfig;
pub use codex_git_utils::GhostSnapshotConfig;
/// Maximum number of bytes of the documentation that will be embedded. Larger
/// files are *silently truncated* to this size so we do not take up too much of
@@ -477,7 +477,7 @@ pub struct Config {
pub file_opener: UriBasedFileOpener,
/// Path to the `codex-linux-sandbox` executable. This must be set if
/// [`crate::exec::SandboxType::LinuxSeccomp`] is used. Note that this
/// [`codex_sandboxing::SandboxType::LinuxSeccomp`] is used. Note that this
/// cannot be set in the config file: it must be set in code via
/// [`ConfigOverrides`].
///
+1 -1
View File
@@ -7,10 +7,10 @@ mod tests;
use crate::config::ConfigToml;
use crate::config_loader::layer_io::LoadedConfigLayers;
use crate::git_info::resolve_root_git_project_for_trust;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigRequirementsWithSources;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::protocol::AskForApproval;
+64 -4
View File
@@ -1,5 +1,7 @@
use crate::codex::TurnContext;
use crate::context_manager::normalize;
use crate::event_mapping::has_non_contextual_dev_message_content;
use crate::event_mapping::is_contextual_dev_message_content;
use crate::event_mapping::is_contextual_user_message_content;
use crate::truncate::TruncationPolicy;
use crate::truncate::approx_bytes_for_tokens;
@@ -40,7 +42,9 @@ pub(crate) struct ContextManager {
/// match the current turn after context updates are persisted.
///
/// When this is `None`, settings diffing treats the next turn as having no
/// baseline and emits a full reinjection of context state.
/// baseline and emits a full reinjection of context state. Rollback may
/// also clear this when it trims a mixed initial-context developer bundle
/// whose non-diff fragments no longer exist in the surviving history.
reference_context_item: Option<TurnContextItem>,
}
@@ -215,6 +219,12 @@ impl ContextManager {
/// - if there are no user turns, this is a no-op
/// - if `num_turns` exceeds the number of user turns, all user turns are dropped while
/// preserving any items that occurred before the first user message.
///
/// If rollback trims a pre-turn developer message that mixes contextual fragments with
/// persistent developer text from `build_initial_context`, this also clears
/// `reference_context_item`. The surviving history no longer contains the full bundle that
/// established the prior baseline, so future turns must fall back to full reinjection instead
/// of diffing against stale state.
pub(crate) fn drop_last_n_user_turns(&mut self, num_turns: u32) {
if num_turns == 0 {
return;
@@ -222,18 +232,21 @@ impl ContextManager {
let snapshot = self.items.clone();
let user_positions = user_message_positions(&snapshot);
let Some(&first_user_idx) = user_positions.first() else {
let Some(&first_instruction_turn_idx) = user_positions.first() else {
self.replace(snapshot);
return;
};
let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
let cut_idx = if n_from_end >= user_positions.len() {
first_user_idx
let mut cut_idx = if n_from_end >= user_positions.len() {
first_instruction_turn_idx
} else {
user_positions[user_positions.len() - n_from_end]
};
cut_idx =
self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx);
self.replace(snapshot[..cut_idx].to_vec());
}
@@ -382,6 +395,53 @@ impl ContextManager {
| ResponseItem::Other => item.clone(),
}
}
/// Walk backward from a rollback cut and trim contiguous pre-turn context-update items.
///
/// Returns the adjusted cut index after removing contextual developer/user items immediately
/// above the rolled-back turn boundary.
///
/// `first_instruction_turn_idx` is the earliest rollback-eligible instruction-turn boundary
/// in `snapshot`; the trim walk never crosses it so any session-prefix items that predate the
/// first real turn survive rollback.
///
/// `cut_idx` is the tentative slice boundary after dropping the requested number of
/// instruction turns, before stripping contextual pre-turn items that sit immediately above
/// that boundary.
///
/// If any trimmed developer message was a mixed `build_initial_context` bundle containing both
/// rollback-trimmable contextual fragments and persistent developer text, this also clears the
/// stored `reference_context_item` baseline so the next real turn falls back to full
/// reinjection.
fn trim_pre_turn_context_updates(
&mut self,
snapshot: &[ResponseItem],
first_instruction_turn_idx: usize,
mut cut_idx: usize,
) -> usize {
while cut_idx > first_instruction_turn_idx {
match &snapshot[cut_idx - 1] {
ResponseItem::Message { role, content, .. }
if role == "developer" && is_contextual_dev_message_content(content) =>
{
if has_non_contextual_dev_message_content(content) {
// Mixed `build_initial_context` bundles are not reconstructible from
// steady-state diffs once trimmed, so the next real turn must fully
// reinject context instead of diffing against a stale baseline.
self.reference_context_item = None;
}
cut_idx -= 1;
}
ResponseItem::Message { role, content, .. }
if role == "user" && is_contextual_user_message_content(content) =>
{
cut_idx -= 1;
}
_ => break,
}
}
cut_idx
}
}
fn truncate_function_output_payload(
@@ -3,8 +3,9 @@ use crate::truncate;
use crate::truncate::TruncationPolicy;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_git::GhostCommit;
use codex_git_utils::GhostCommit;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
@@ -18,12 +19,16 @@ use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnContextItem;
use image::ImageBuffer;
use image::ImageFormat;
use image::Rgba;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use std::path::PathBuf;
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
@@ -46,6 +51,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
AgentPath::root().join("worker").unwrap(),
Vec::new(),
text.to_string(),
true,
);
ResponseItem::Message {
id: None,
@@ -90,6 +96,56 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
}
}
fn developer_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: texts
.iter()
.map(|text| ContentItem::InputText {
text: (*text).to_string(),
})
.collect(),
end_turn: None,
phase: None,
}
}
fn reference_context_item() -> TurnContextItem {
TurnContextItem {
turn_id: Some("reference-turn".to_string()),
trace_id: None,
cwd: PathBuf::from("/tmp/reference-cwd"),
current_date: Some("2026-03-23".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
model: "gpt-test".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: Some(false),
effort: None,
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)),
}
}
fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem {
ResponseItem::CustomToolCallOutput {
call_id: call_id.to_string(),
@@ -252,6 +308,15 @@ fn inter_agent_assistant_messages_are_turn_boundaries() {
assert!(is_user_turn_boundary(&item));
}
#[test]
fn for_prompt_preserves_inter_agent_assistant_messages() {
let item = inter_agent_assistant_msg("continue");
let history = create_history_with_items(vec![item.clone()]);
assert_eq!(history.raw_items(), std::slice::from_ref(&item));
assert_eq!(history.for_prompt(&default_input_modalities()), vec![item]);
}
#[test]
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
let first_turn = user_input_text_msg("first");
@@ -851,6 +916,75 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
assert_eq!(history.for_prompt(&modalities), expected_prefix_only);
}
#[test]
fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
let items = vec![
assistant_msg("session prefix item"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
user_input_text_msg(
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
),
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
];
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
let reference_context_item = reference_context_item();
history.set_reference_context_item(Some(reference_context_item.clone()));
history.drop_last_n_user_turns(1);
assert_eq!(
history.clone().for_prompt(&modalities),
vec![
assistant_msg("session prefix item"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
]
);
assert_eq!(
serde_json::to_value(history.reference_context_item())
.expect("serialize retained reference context item"),
serde_json::to_value(Some(reference_context_item))
.expect("serialize expected reference context item")
);
}
#[test]
fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles() {
let items = vec![
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg_with_fragments(&[
"<permissions instructions>contextual permissions</permissions instructions>",
"persistent plugin instructions",
]),
user_input_text_msg(
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
),
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
];
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
history.set_reference_context_item(Some(reference_context_item()));
history.drop_last_n_user_turns(1);
assert_eq!(
history.clone().for_prompt(&modalities),
vec![
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
]
);
assert!(history.reference_context_item().is_none());
}
#[test]
fn remove_first_item_handles_custom_tool_pair() {
let items = vec![
+15
View File
@@ -460,6 +460,21 @@ impl std::fmt::Display for UsageLimitReachedError {
"You've hit your usage limit.{}",
retry_suffix(self.resets_at.as_ref())
),
Some(PlanType::Unknown(plan))
if plan.eq_ignore_ascii_case("self_serve_business_usage_based") =>
{
match self
.rate_limits
.as_ref()
.and_then(|snapshot| snapshot.credits.as_ref())
.map(|credits| credits.has_credits)
{
Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue."
.to_string(),
Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue."
.to_string(),
}
}
Some(PlanType::Unknown(_)) | None => format!(
"You've hit your usage limit.{}",
retry_suffix(self.resets_at.as_ref())
+40
View File
@@ -14,6 +14,8 @@ use codex_protocol::models::is_image_close_tag_text;
use codex_protocol::models::is_image_open_tag_text;
use codex_protocol::models::is_local_image_close_tag_text;
use codex_protocol::models::is_local_image_open_tag_text;
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
use codex_protocol::user_input::UserInput;
use tracing::warn;
use uuid::Uuid;
@@ -22,10 +24,48 @@ use crate::contextual_user_message::is_contextual_user_fragment;
use crate::contextual_user_message::parse_visible_hook_prompt_message;
use crate::web_search::web_search_action_detail;
const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<permissions instructions>",
"<model_switch>",
COLLABORATION_MODE_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
"<personality_spec>",
];
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
message.iter().any(is_contextual_user_fragment)
}
/// Returns true when a developer message contains any rollback-trimmable contextual fragment.
///
/// `build_initial_context` can bundle these fragments together with persistent developer text in a
/// single developer message, so callers that care about invalidating a stored reference baseline
/// should pair this with `has_non_contextual_dev_message_content`.
pub(crate) fn is_contextual_dev_message_content(message: &[ContentItem]) -> bool {
message.iter().any(is_contextual_dev_fragment)
}
/// Returns true when a developer message contains any fragment that is not part of the
/// rollback-trimmable contextual prefix set.
pub(crate) fn has_non_contextual_dev_message_content(message: &[ContentItem]) -> bool {
message
.iter()
.any(|content_item| !is_contextual_dev_fragment(content_item))
}
fn is_contextual_dev_fragment(content_item: &ContentItem) -> bool {
let ContentItem::InputText { text } = content_item else {
return false;
};
let trimmed = text.trim_start();
CONTEXTUAL_DEVELOPER_PREFIXES.iter().any(|prefix| {
trimmed
.get(..prefix.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
})
}
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
if is_contextual_user_message_content(message) {
return None;
+15 -35
View File
@@ -24,20 +24,23 @@ use crate::protocol::EventMsg;
use crate::protocol::ExecCommandOutputDeltaEvent;
use crate::protocol::ExecOutputStream;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxPermissions;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
#[cfg(any(target_os = "windows", test))]
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use codex_utils_pty::process_group::kill_child_process_group;
@@ -178,31 +181,6 @@ impl ExecCapturePolicy {
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum SandboxType {
None,
/// Only available on macOS.
MacosSeatbelt,
/// Only available on Linux.
LinuxSeccomp,
/// Only available on Windows.
WindowsRestrictedToken,
}
impl SandboxType {
pub(crate) fn as_metric_tag(self) -> &'static str {
match self {
SandboxType::None => "none",
SandboxType::MacosSeatbelt => "seatbelt",
SandboxType::LinuxSeccomp => "seccomp",
SandboxType::WindowsRestrictedToken => "windows_sandbox",
}
}
}
#[derive(Clone)]
pub struct StdoutStream {
pub sub_id: String,
@@ -279,22 +257,23 @@ pub fn build_exec_request(
))
})?;
let spec = CommandSpec {
let manager = SandboxManager::new();
let command = SandboxCommand {
program: program.clone(),
args: args.to_vec(),
cwd,
env,
additional_permissions: None,
};
let options = ExecOptions {
expiration,
capture_policy,
sandbox_permissions,
additional_permissions: None,
justification,
};
let manager = SandboxManager::new();
let exec_req = manager
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
.transform(SandboxTransformRequest {
command,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
@@ -309,6 +288,7 @@ pub fn build_exec_request(
windows_sandbox_level,
windows_sandbox_private_desktop,
})
.map(|request| ExecRequest::from_sandbox_exec_request(request, options))
.map_err(CodexErr::from)?;
Ok(exec_req)
}
@@ -620,7 +600,7 @@ fn finalize_exec_result(
pub(crate) mod errors {
use super::CodexErr;
use crate::sandboxing::SandboxTransformError;
use codex_sandboxing::SandboxTransformError;
impl From<SandboxTransformError> for CodexErr {
fn from(err: SandboxTransformError) -> Self {
+2 -1
View File
@@ -1,5 +1,6 @@
use super::*;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_sandboxing::SandboxType;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::time::Duration;
@@ -542,7 +543,7 @@ fn windows_elevated_sandbox_allows_restricted_read_only_policies() {
#[test]
fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() {
let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let expected = codex_sandboxing::get_platform_sandbox(false).unwrap_or(SandboxType::None);
assert_eq!(
select_process_exec_tool_sandbox_type(
+446 -222
View File
@@ -1,13 +1,15 @@
//! Watches skill roots for changes and broadcasts coarse-grained
//! `FileWatcherEvent`s that higher-level components react to on the next turn.
//! Watches subscribed files or directories and routes coarse-grained change
//! notifications to the subscribers that own matching watched paths.
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use notify::Event;
@@ -16,22 +18,162 @@ use notify::RecommendedWatcher;
use notify::RecursiveMode;
use notify::Watcher;
use tokio::runtime::Handle;
use tokio::sync::broadcast;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::time::Instant;
use tokio::time::sleep_until;
use tracing::warn;
use crate::config::Config;
use crate::skills::SkillsManager;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileWatcherEvent {
SkillsChanged { paths: Vec<PathBuf> },
/// Coalesced file change notification for a subscriber.
pub struct FileWatcherEvent {
/// Changed paths delivered in sorted order with duplicates removed.
pub paths: Vec<PathBuf>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
/// Path subscription registered by a [`FileWatcherSubscriber`].
pub struct WatchPath {
/// Root path to watch.
pub path: PathBuf,
/// Whether events below `path` should match recursively.
pub recursive: bool,
}
type SubscriberId = u64;
#[derive(Default)]
struct WatchState {
skills_root_ref_counts: HashMap<PathBuf, usize>,
next_subscriber_id: SubscriberId,
path_ref_counts: HashMap<PathBuf, PathWatchCounts>,
subscribers: HashMap<SubscriberId, SubscriberState>,
}
struct SubscriberState {
watched_paths: HashMap<WatchPath, usize>,
tx: WatchSender,
}
/// Receives coalesced change notifications for a single subscriber.
pub struct Receiver {
inner: Arc<ReceiverInner>,
}
struct WatchSender {
inner: Arc<ReceiverInner>,
}
struct ReceiverInner {
changed_paths: AsyncMutex<BTreeSet<PathBuf>>,
notify: Notify,
sender_count: AtomicUsize,
}
impl Receiver {
/// Waits for the next batch of changed paths, or returns `None` once the
/// corresponding subscriber has been removed and no more events can arrive.
pub async fn recv(&mut self) -> Option<FileWatcherEvent> {
loop {
let notified = self.inner.notify.notified();
{
let mut changed_paths = self.inner.changed_paths.lock().await;
if !changed_paths.is_empty() {
return Some(FileWatcherEvent {
paths: std::mem::take(&mut *changed_paths).into_iter().collect(),
});
}
if self.inner.sender_count.load(Ordering::Acquire) == 0 {
return None;
}
}
notified.await;
}
}
}
impl WatchSender {
async fn add_changed_paths(&self, paths: &[PathBuf]) {
if paths.is_empty() {
return;
}
let mut changed_paths = self.inner.changed_paths.lock().await;
let previous_len = changed_paths.len();
changed_paths.extend(paths.iter().cloned());
if changed_paths.len() != previous_len {
self.inner.notify.notify_one();
}
}
}
impl Clone for WatchSender {
fn clone(&self) -> Self {
self.inner.sender_count.fetch_add(1, Ordering::Relaxed);
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl Drop for WatchSender {
fn drop(&mut self) {
if self.inner.sender_count.fetch_sub(1, Ordering::AcqRel) == 1 {
self.inner.notify.notify_waiters();
}
}
}
fn watch_channel() -> (WatchSender, Receiver) {
let inner = Arc::new(ReceiverInner {
changed_paths: AsyncMutex::new(BTreeSet::new()),
notify: Notify::new(),
sender_count: AtomicUsize::new(1),
});
(
WatchSender {
inner: Arc::clone(&inner),
},
Receiver { inner },
)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct PathWatchCounts {
non_recursive: usize,
recursive: usize,
}
impl PathWatchCounts {
fn increment(&mut self, recursive: bool, amount: usize) {
if recursive {
self.recursive += amount;
} else {
self.non_recursive += amount;
}
}
fn decrement(&mut self, recursive: bool, amount: usize) {
if recursive {
self.recursive = self.recursive.saturating_sub(amount);
} else {
self.non_recursive = self.non_recursive.saturating_sub(amount);
}
}
fn effective_mode(self) -> Option<RecursiveMode> {
if self.recursive > 0 {
Some(RecursiveMode::Recursive)
} else if self.non_recursive > 0 {
Some(RecursiveMode::NonRecursive)
} else {
None
}
}
fn is_empty(self) -> bool {
self.non_recursive == 0 && self.recursive == 0
}
}
struct FileWatcherInner {
@@ -39,73 +181,95 @@ struct FileWatcherInner {
watched_paths: HashMap<PathBuf, RecursiveMode>,
}
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
/// Coalesces bursts of paths and emits at most once per interval.
struct ThrottledPaths {
pending: HashSet<PathBuf>,
next_allowed_at: Instant,
/// Coalesces bursts of watch notifications and emits at most once per interval.
pub struct ThrottledWatchReceiver {
rx: Receiver,
interval: Duration,
next_allowed: Option<Instant>,
}
impl ThrottledPaths {
fn new(now: Instant) -> Self {
impl ThrottledWatchReceiver {
/// Creates a throttling wrapper around a raw watcher [`Receiver`].
pub fn new(rx: Receiver, interval: Duration) -> Self {
Self {
pending: HashSet::new(),
next_allowed_at: now,
rx,
interval,
next_allowed: None,
}
}
fn add(&mut self, paths: Vec<PathBuf>) {
self.pending.extend(paths);
}
fn next_deadline(&self, now: Instant) -> Option<Instant> {
(!self.pending.is_empty() && now < self.next_allowed_at).then_some(self.next_allowed_at)
}
fn take_ready(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
if self.pending.is_empty() || now < self.next_allowed_at {
return None;
/// Receives the next event, enforcing the configured minimum delay after
/// the previous emission.
pub async fn recv(&mut self) -> Option<FileWatcherEvent> {
if let Some(next_allowed) = self.next_allowed {
sleep_until(next_allowed).await;
}
Some(self.take_with_next_allowed(now))
}
fn take_pending(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
if self.pending.is_empty() {
return None;
let event = self.rx.recv().await;
if event.is_some() {
self.next_allowed = Some(Instant::now() + self.interval);
}
Some(self.take_with_next_allowed(now))
}
fn take_with_next_allowed(&mut self, now: Instant) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = self.pending.drain().collect();
paths.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
self.next_allowed_at = now + WATCHER_THROTTLE_INTERVAL;
paths
event
}
}
pub(crate) struct FileWatcher {
inner: Option<Mutex<FileWatcherInner>>,
state: Arc<RwLock<WatchState>>,
tx: broadcast::Sender<FileWatcherEvent>,
/// Handle used to register watched paths for one logical consumer.
pub struct FileWatcherSubscriber {
id: SubscriberId,
file_watcher: Arc<FileWatcher>,
}
pub(crate) struct WatchRegistration {
impl FileWatcherSubscriber {
/// Registers the provided paths for this subscriber and returns an RAII
/// guard that unregisters them on drop.
pub fn register_paths(&self, watched_paths: Vec<WatchPath>) -> WatchRegistration {
let watched_paths = dedupe_watched_paths(watched_paths);
self.file_watcher.register_paths(self.id, &watched_paths);
WatchRegistration {
file_watcher: Arc::downgrade(&self.file_watcher),
subscriber_id: self.id,
watched_paths,
}
}
#[cfg(test)]
pub(crate) fn register_path(&self, path: PathBuf, recursive: bool) -> WatchRegistration {
self.register_paths(vec![WatchPath { path, recursive }])
}
}
impl Drop for FileWatcherSubscriber {
fn drop(&mut self) {
self.file_watcher.remove_subscriber(self.id);
}
}
/// RAII guard for a set of active path registrations.
pub struct WatchRegistration {
file_watcher: std::sync::Weak<FileWatcher>,
roots: Vec<PathBuf>,
subscriber_id: SubscriberId,
watched_paths: Vec<WatchPath>,
}
impl Drop for WatchRegistration {
fn drop(&mut self) {
if let Some(file_watcher) = self.file_watcher.upgrade() {
file_watcher.unregister_roots(&self.roots);
file_watcher.unregister_paths(self.subscriber_id, &self.watched_paths);
}
}
}
/// Multi-subscriber file watcher built on top of `notify`.
pub struct FileWatcher {
inner: Option<Mutex<FileWatcherInner>>,
state: Arc<RwLock<WatchState>>,
}
impl FileWatcher {
pub(crate) fn new(_codex_home: PathBuf) -> notify::Result<Self> {
/// Creates a live filesystem watcher and starts its background event loop
/// on the current Tokio runtime.
pub fn new() -> notify::Result<Self> {
let (raw_tx, raw_rx) = mpsc::unbounded_channel();
let raw_tx_clone = raw_tx;
let watcher = notify::recommended_watcher(move |res| {
@@ -115,109 +279,101 @@ impl FileWatcher {
watcher,
watched_paths: HashMap::new(),
};
let (tx, _) = broadcast::channel(128);
let state = Arc::new(RwLock::new(WatchState {
skills_root_ref_counts: HashMap::new(),
}));
let state = Arc::new(RwLock::new(WatchState::default()));
let file_watcher = Self {
inner: Some(Mutex::new(inner)),
state: Arc::clone(&state),
tx: tx.clone(),
state,
};
file_watcher.spawn_event_loop(raw_rx, state, tx);
file_watcher.spawn_event_loop(raw_rx);
Ok(file_watcher)
}
pub(crate) fn noop() -> Self {
let (tx, _) = broadcast::channel(1);
/// Creates an inert watcher that only supports test-driven synthetic
/// notifications.
pub fn noop() -> Self {
Self {
inner: None,
state: Arc::new(RwLock::new(WatchState {
skills_root_ref_counts: HashMap::new(),
})),
tx,
state: Arc::new(RwLock::new(WatchState::default())),
}
}
pub(crate) fn subscribe(&self) -> broadcast::Receiver<FileWatcherEvent> {
self.tx.subscribe()
/// Adds a new subscriber and returns both its registration handle and its
/// dedicated event receiver.
pub fn add_subscriber(self: &Arc<Self>) -> (FileWatcherSubscriber, Receiver) {
let (tx, rx) = watch_channel();
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let subscriber_id = state.next_subscriber_id;
state.next_subscriber_id += 1;
state.subscribers.insert(
subscriber_id,
SubscriberState {
watched_paths: HashMap::new(),
tx,
},
);
let subscriber = FileWatcherSubscriber {
id: subscriber_id,
file_watcher: self.clone(),
};
(subscriber, rx)
}
pub(crate) fn register_config(
self: &Arc<Self>,
config: &Config,
skills_manager: &SkillsManager,
) -> WatchRegistration {
let deduped_roots: HashSet<PathBuf> = skills_manager
.skill_roots_for_config(config)
.into_iter()
.map(|root| root.path)
.collect();
let mut registered_roots: Vec<PathBuf> = deduped_roots.into_iter().collect();
registered_roots.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
for root in &registered_roots {
self.register_skills_root(root.clone());
}
fn register_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
WatchRegistration {
file_watcher: Arc::downgrade(self),
roots: registered_roots,
for watched_path in watched_paths {
{
let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else {
return;
};
*subscriber
.watched_paths
.entry(watched_path.clone())
.or_default() += 1;
}
let counts = state
.path_ref_counts
.entry(watched_path.path.clone())
.or_default();
let previous_mode = counts.effective_mode();
counts.increment(watched_path.recursive, /*amount*/ 1);
let next_mode = counts.effective_mode();
if previous_mode != next_mode {
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
}
}
}
// Bridge `notify`'s callback-based events into the Tokio runtime and
// broadcast coarse-grained change signals to subscribers.
fn spawn_event_loop(
&self,
mut raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>,
state: Arc<RwLock<WatchState>>,
tx: broadcast::Sender<FileWatcherEvent>,
) {
// notify the matching subscribers.
fn spawn_event_loop(&self, mut raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>) {
if let Ok(handle) = Handle::try_current() {
let state = Arc::clone(&self.state);
handle.spawn(async move {
let now = Instant::now();
let mut skills = ThrottledPaths::new(now);
loop {
let now = Instant::now();
let next_deadline = skills.next_deadline(now);
let timer_deadline = next_deadline
.unwrap_or_else(|| now + Duration::from_secs(60 * 60 * 24 * 365));
let timer = sleep_until(timer_deadline);
tokio::pin!(timer);
tokio::select! {
res = raw_rx.recv() => {
match res {
Some(Ok(event)) => {
let skills_paths = classify_event(&event, &state);
let now = Instant::now();
skills.add(skills_paths);
if let Some(paths) = skills.take_ready(now) {
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
}
}
Some(Err(err)) => {
warn!("file watcher error: {err}");
}
None => {
// Flush any pending changes before shutdown so subscribers
// see the latest state.
let now = Instant::now();
if let Some(paths) = skills.take_pending(now) {
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
}
break;
}
match raw_rx.recv().await {
Some(Ok(event)) => {
if !is_mutating_event(&event) {
continue;
}
}
_ = &mut timer => {
let now = Instant::now();
if let Some(paths) = skills.take_ready(now) {
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
if event.paths.is_empty() {
continue;
}
Self::notify_subscribers(&state, &event.paths).await;
}
Some(Err(err)) => {
warn!("file watcher error: {err}");
}
None => break,
}
}
});
@@ -226,127 +382,195 @@ impl FileWatcher {
}
}
fn register_skills_root(&self, root: PathBuf) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let count = state
.skills_root_ref_counts
.entry(root.clone())
.or_insert(0);
*count += 1;
if *count == 1 {
self.watch_path(root, RecursiveMode::Recursive);
}
}
fn unregister_roots(&self, roots: &[PathBuf]) {
fn unregister_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
for root in roots {
let mut should_unwatch = false;
if let Some(count) = state.skills_root_ref_counts.get_mut(root) {
if *count > 1 {
*count -= 1;
} else {
state.skills_root_ref_counts.remove(root);
should_unwatch = true;
for watched_path in watched_paths {
{
let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else {
return;
};
let Some(subscriber_count) = subscriber.watched_paths.get_mut(watched_path) else {
continue;
};
*subscriber_count = subscriber_count.saturating_sub(1);
if *subscriber_count == 0 {
subscriber.watched_paths.remove(watched_path);
}
}
if !should_unwatch {
continue;
}
let Some(inner) = &self.inner else {
let Some(counts) = state.path_ref_counts.get_mut(&watched_path.path) else {
continue;
};
if inner_guard.is_none() {
let guard = inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
inner_guard = Some(guard);
let previous_mode = counts.effective_mode();
counts.decrement(watched_path.recursive, /*amount*/ 1);
let next_mode = counts.effective_mode();
if counts.is_empty() {
state.path_ref_counts.remove(&watched_path.path);
}
let Some(guard) = inner_guard.as_mut() else {
continue;
};
if guard.watched_paths.remove(root).is_none() {
continue;
}
if let Err(err) = guard.watcher.unwatch(root) {
warn!("failed to unwatch {}: {err}", root.display());
if previous_mode != next_mode {
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
}
}
}
fn watch_path(&self, path: PathBuf, mode: RecursiveMode) {
fn remove_subscriber(&self, subscriber_id: SubscriberId) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(subscriber) = state.subscribers.remove(&subscriber_id) else {
return;
};
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
for (watched_path, count) in subscriber.watched_paths {
let Some(path_counts) = state.path_ref_counts.get_mut(&watched_path.path) else {
continue;
};
let previous_mode = path_counts.effective_mode();
path_counts.decrement(watched_path.recursive, count);
let next_mode = path_counts.effective_mode();
if path_counts.is_empty() {
state.path_ref_counts.remove(&watched_path.path);
}
if previous_mode != next_mode {
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
}
}
}
fn reconfigure_watch<'a>(
&'a self,
path: &Path,
next_mode: Option<RecursiveMode>,
inner_guard: &mut Option<std::sync::MutexGuard<'a, FileWatcherInner>>,
) {
let Some(inner) = &self.inner else {
return;
};
if inner_guard.is_none() {
let guard = inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*inner_guard = Some(guard);
}
let Some(guard) = inner_guard.as_mut() else {
return;
};
let existing_mode = guard.watched_paths.get(path).copied();
if existing_mode == next_mode {
return;
}
if existing_mode.is_some() {
if let Err(err) = guard.watcher.unwatch(path) {
warn!("failed to unwatch {}: {err}", path.display());
}
guard.watched_paths.remove(path);
}
let Some(next_mode) = next_mode else {
return;
};
if !path.exists() {
return;
}
let watch_path = path;
let mut guard = inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(existing) = guard.watched_paths.get(&watch_path) {
if *existing == RecursiveMode::Recursive || *existing == mode {
return;
}
if let Err(err) = guard.watcher.unwatch(&watch_path) {
warn!("failed to unwatch {}: {err}", watch_path.display());
}
}
if let Err(err) = guard.watcher.watch(&watch_path, mode) {
warn!("failed to watch {}: {err}", watch_path.display());
if let Err(err) = guard.watcher.watch(path, next_mode) {
warn!("failed to watch {}: {err}", path.display());
return;
}
guard.watched_paths.insert(watch_path, mode);
guard.watched_paths.insert(path.to_path_buf(), next_mode);
}
async fn notify_subscribers(state: &RwLock<WatchState>, event_paths: &[PathBuf]) {
let subscribers_to_notify: Vec<(WatchSender, Vec<PathBuf>)> = {
let state = state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state
.subscribers
.values()
.filter_map(|subscriber| {
let changed_paths: Vec<PathBuf> = event_paths
.iter()
.filter(|event_path| {
subscriber.watched_paths.keys().any(|watched_path| {
watch_path_matches_event(watched_path, event_path)
})
})
.cloned()
.collect();
(!changed_paths.is_empty()).then_some((subscriber.tx.clone(), changed_paths))
})
.collect()
};
for (subscriber, changed_paths) in subscribers_to_notify {
subscriber.add_changed_paths(&changed_paths).await;
}
}
#[cfg(test)]
pub(crate) async fn send_paths_for_test(&self, paths: Vec<PathBuf>) {
Self::notify_subscribers(&self.state, &paths).await;
}
#[cfg(test)]
pub(crate) fn spawn_event_loop_for_test(
&self,
raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>,
) {
self.spawn_event_loop(raw_rx);
}
#[cfg(test)]
pub(crate) fn watch_counts_for_test(&self, path: &Path) -> Option<(usize, usize)> {
let state = self
.state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state
.path_ref_counts
.get(path)
.map(|counts| (counts.non_recursive, counts.recursive))
}
}
fn classify_event(event: &Event, state: &RwLock<WatchState>) -> Vec<PathBuf> {
if !matches!(
fn is_mutating_event(event: &Event) -> bool {
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) {
return Vec::new();
}
let mut skills_paths = Vec::new();
let skills_roots = match state.read() {
Ok(state) => state
.skills_root_ref_counts
.keys()
.cloned()
.collect::<HashSet<_>>(),
Err(err) => {
let state = err.into_inner();
state
.skills_root_ref_counts
.keys()
.cloned()
.collect::<HashSet<_>>()
}
};
for path in &event.paths {
if is_skills_path(path, &skills_roots) {
skills_paths.push(path.clone());
}
}
skills_paths
)
}
fn is_skills_path(path: &Path, roots: &HashSet<PathBuf>) -> bool {
roots.iter().any(|root| path.starts_with(root))
fn dedupe_watched_paths(mut watched_paths: Vec<WatchPath>) -> Vec<WatchPath> {
watched_paths.sort_unstable_by(|a, b| {
a.path
.as_os_str()
.cmp(b.path.as_os_str())
.then(a.recursive.cmp(&b.recursive))
});
watched_paths.dedup();
watched_paths
}
fn watch_path_matches_event(watched_path: &WatchPath, event_path: &Path) -> bool {
if event_path == watched_path.path {
return true;
}
if watched_path.path.starts_with(event_path) {
return true;
}
if !event_path.starts_with(&watched_path.path) {
return false;
}
watched_path.recursive || event_path.parent() == Some(watched_path.path.as_path())
}
#[cfg(test)]
+273 -165
View File
@@ -1,13 +1,13 @@
use super::*;
use notify::EventKind;
use notify::event::AccessKind;
use notify::event::AccessMode;
use notify::event::CreateKind;
use notify::event::ModifyKind;
use notify::event::RemoveKind;
use pretty_assertions::assert_eq;
use tokio::time::timeout;
const TEST_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
fn path(name: &str) -> PathBuf {
PathBuf::from(name)
}
@@ -20,147 +20,202 @@ fn notify_event(kind: EventKind, paths: Vec<PathBuf>) -> Event {
event
}
#[test]
fn throttles_and_coalesces_within_interval() {
let start = Instant::now();
let mut throttled = ThrottledPaths::new(start);
#[tokio::test]
async fn throttled_receiver_coalesces_within_interval() {
let (tx, rx) = watch_channel();
let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
throttled.add(vec![path("a")]);
let first = throttled.take_ready(start).expect("first emit");
assert_eq!(first, vec![path("a")]);
throttled.add(vec![path("b"), path("c")]);
assert_eq!(throttled.take_ready(start), None);
let second = throttled
.take_ready(start + WATCHER_THROTTLE_INTERVAL)
.expect("coalesced emit");
assert_eq!(second, vec![path("b"), path("c")]);
}
#[test]
fn flushes_pending_on_shutdown() {
let start = Instant::now();
let mut throttled = ThrottledPaths::new(start);
throttled.add(vec![path("a")]);
let _ = throttled.take_ready(start).expect("first emit");
throttled.add(vec![path("b")]);
assert_eq!(throttled.take_ready(start), None);
let flushed = throttled
.take_pending(start)
.expect("shutdown flush emits pending paths");
assert_eq!(flushed, vec![path("b")]);
}
#[test]
fn classify_event_filters_to_skills_roots() {
let root = path("/tmp/skills");
let state = RwLock::new(WatchState {
skills_root_ref_counts: HashMap::from([(root.clone(), 1)]),
});
let event = notify_event(
EventKind::Create(CreateKind::Any),
vec![
root.join("demo/SKILL.md"),
path("/tmp/other/not-a-skill.txt"),
],
);
let classified = classify_event(&event, &state);
assert_eq!(classified, vec![root.join("demo/SKILL.md")]);
}
#[test]
fn classify_event_supports_multiple_roots_without_prefix_false_positives() {
let root_a = path("/tmp/skills");
let root_b = path("/tmp/workspace/.codex/skills");
let state = RwLock::new(WatchState {
skills_root_ref_counts: HashMap::from([(root_a.clone(), 1), (root_b.clone(), 1)]),
});
let event = notify_event(
EventKind::Modify(ModifyKind::Any),
vec![
root_a.join("alpha/SKILL.md"),
path("/tmp/skills-extra/not-under-skills.txt"),
root_b.join("beta/SKILL.md"),
],
);
let classified = classify_event(&event, &state);
tx.add_changed_paths(&[path("a")]).await;
let first = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("first emit timeout");
assert_eq!(
classified,
vec![root_a.join("alpha/SKILL.md"), root_b.join("beta/SKILL.md")]
first,
Some(FileWatcherEvent {
paths: vec![path("a")],
})
);
tx.add_changed_paths(&[path("b"), path("c")]).await;
let blocked = timeout(TEST_THROTTLE_INTERVAL / 2, throttled.recv()).await;
assert_eq!(blocked.is_err(), true);
let second = timeout(TEST_THROTTLE_INTERVAL * 2, throttled.recv())
.await
.expect("second emit timeout");
assert_eq!(
second,
Some(FileWatcherEvent {
paths: vec![path("b"), path("c")],
})
);
}
#[tokio::test]
async fn throttled_receiver_flushes_pending_on_shutdown() {
let (tx, rx) = watch_channel();
let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
tx.add_changed_paths(&[path("a")]).await;
let first = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("first emit timeout");
assert_eq!(
first,
Some(FileWatcherEvent {
paths: vec![path("a")],
})
);
tx.add_changed_paths(&[path("b")]).await;
drop(tx);
let second = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("shutdown flush timeout");
assert_eq!(
second,
Some(FileWatcherEvent {
paths: vec![path("b")],
})
);
let closed = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("closed recv timeout");
assert_eq!(closed, None);
}
#[test]
fn is_mutating_event_filters_non_mutating_event_kinds() {
assert_eq!(
is_mutating_event(&notify_event(
EventKind::Create(CreateKind::Any),
vec![path("/tmp/created")]
)),
true
);
assert_eq!(
is_mutating_event(&notify_event(
EventKind::Modify(ModifyKind::Any),
vec![path("/tmp/modified")]
)),
true
);
assert_eq!(
is_mutating_event(&notify_event(
EventKind::Access(AccessKind::Open(AccessMode::Any)),
vec![path("/tmp/accessed")]
)),
false
);
}
#[test]
fn classify_event_ignores_non_mutating_event_kinds() {
let root = path("/tmp/skills");
let state = RwLock::new(WatchState {
skills_root_ref_counts: HashMap::from([(root.clone(), 1)]),
});
let path = root.join("demo/SKILL.md");
let access_event = notify_event(
EventKind::Access(AccessKind::Open(AccessMode::Any)),
vec![path.clone()],
);
assert_eq!(classify_event(&access_event, &state), Vec::<PathBuf>::new());
let any_event = notify_event(EventKind::Any, vec![path.clone()]);
assert_eq!(classify_event(&any_event, &state), Vec::<PathBuf>::new());
let other_event = notify_event(EventKind::Other, vec![path]);
assert_eq!(classify_event(&other_event, &state), Vec::<PathBuf>::new());
}
#[test]
fn register_skills_root_dedupes_state_entries() {
let watcher = FileWatcher::noop();
let root = path("/tmp/skills");
watcher.register_skills_root(root.clone());
watcher.register_skills_root(root);
watcher.register_skills_root(path("/tmp/other-skills"));
let state = watcher.state.read().expect("state lock");
assert_eq!(state.skills_root_ref_counts.len(), 2);
}
#[test]
fn watch_registration_drop_unregisters_roots() {
fn register_dedupes_by_path_and_scope() {
let watcher = Arc::new(FileWatcher::noop());
let root = path("/tmp/skills");
watcher.register_skills_root(root.clone());
let registration = WatchRegistration {
file_watcher: Arc::downgrade(&watcher),
roots: vec![root],
};
let (subscriber, _rx) = watcher.add_subscriber();
let _first = subscriber.register_path(path("/tmp/skills"), false);
let _second = subscriber.register_path(path("/tmp/skills"), false);
let _third = subscriber.register_path(path("/tmp/skills"), true);
let _fourth = subscriber.register_path(path("/tmp/other-skills"), true);
assert_eq!(
watcher.watch_counts_for_test(&path("/tmp/skills")),
Some((2, 1))
);
assert_eq!(
watcher.watch_counts_for_test(&path("/tmp/other-skills")),
Some((0, 1))
);
}
#[test]
fn watch_registration_drop_unregisters_paths() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, _rx) = watcher.add_subscriber();
let registration = subscriber.register_path(path("/tmp/skills"), true);
drop(registration);
let state = watcher.state.read().expect("state lock");
assert_eq!(state.skills_root_ref_counts.len(), 0);
assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None);
}
#[test]
fn subscriber_drop_unregisters_paths() {
let watcher = Arc::new(FileWatcher::noop());
let registration = {
let (subscriber, _rx) = watcher.add_subscriber();
subscriber.register_path(path("/tmp/skills"), true)
};
assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None);
drop(registration);
}
#[tokio::test]
async fn receiver_closes_when_subscriber_drops() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, mut rx) = watcher.add_subscriber();
drop(subscriber);
let closed = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("closed recv timeout");
assert_eq!(closed, None);
}
#[test]
fn recursive_registration_downgrades_to_non_recursive_after_drop() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let root = temp_dir.path().join("watched-dir");
std::fs::create_dir(&root).expect("create root");
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
let (subscriber, _rx) = watcher.add_subscriber();
let non_recursive = subscriber.register_path(root.clone(), false);
let recursive = subscriber.register_path(root.clone(), true);
{
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner = inner.lock().expect("inner lock");
assert_eq!(
inner.watched_paths.get(&root),
Some(&RecursiveMode::Recursive)
);
}
drop(recursive);
{
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner = inner.lock().expect("inner lock");
assert_eq!(
inner.watched_paths.get(&root),
Some(&RecursiveMode::NonRecursive)
);
}
drop(non_recursive);
}
#[test]
fn unregister_holds_state_lock_until_unwatch_finishes() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let root = temp_dir.path().join("skills");
let root = temp_dir.path().join("watched-dir");
std::fs::create_dir(&root).expect("create root");
let watcher = Arc::new(FileWatcher::new(temp_dir.path().to_path_buf()).expect("watcher"));
watcher.register_skills_root(root.clone());
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
let (unregister_subscriber, _unregister_rx) = watcher.add_subscriber();
let (register_subscriber, _register_rx) = watcher.add_subscriber();
let registration = unregister_subscriber.register_path(root.clone(), true);
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner_guard = inner.lock().expect("inner lock");
let unregister_watcher = Arc::clone(&watcher);
let unregister_root = root.clone();
let unregister_thread = std::thread::spawn(move || {
unregister_watcher.unregister_roots(&[unregister_root]);
drop(registration);
});
let state_lock_observed = (0..100).any(|_| {
@@ -172,75 +227,128 @@ fn unregister_holds_state_lock_until_unwatch_finishes() {
});
assert_eq!(state_lock_observed, true);
let register_watcher = Arc::clone(&watcher);
let register_root = root.clone();
let register_thread = std::thread::spawn(move || {
register_watcher.register_skills_root(register_root);
let registration = register_subscriber.register_path(register_root, false);
(register_subscriber, registration)
});
drop(inner_guard);
unregister_thread.join().expect("unregister join");
register_thread.join().expect("register join");
let (register_subscriber, non_recursive) = register_thread.join().expect("register join");
let state = watcher.state.read().expect("state lock");
assert_eq!(state.skills_root_ref_counts.get(&root), Some(&1));
drop(state);
assert_eq!(watcher.watch_counts_for_test(&root), Some((1, 0)));
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner = inner.lock().expect("inner lock");
assert_eq!(
inner.watched_paths.get(&root),
Some(&RecursiveMode::Recursive)
Some(&RecursiveMode::NonRecursive)
);
drop(inner);
drop(non_recursive);
drop(register_subscriber);
}
#[tokio::test]
async fn matching_subscribers_are_notified() {
let watcher = Arc::new(FileWatcher::noop());
let (skills_subscriber, skills_rx) = watcher.add_subscriber();
let (plugins_subscriber, plugins_rx) = watcher.add_subscriber();
let _skills = skills_subscriber.register_path(path("/tmp/skills"), true);
let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), true);
let mut skills_rx = ThrottledWatchReceiver::new(skills_rx, TEST_THROTTLE_INTERVAL);
let mut plugins_rx = ThrottledWatchReceiver::new(plugins_rx, TEST_THROTTLE_INTERVAL);
watcher
.send_paths_for_test(vec![path("/tmp/skills/rust/SKILL.md")])
.await;
let skills_event = timeout(Duration::from_secs(1), skills_rx.recv())
.await
.expect("skills change timeout")
.expect("skills change");
assert_eq!(
skills_event,
FileWatcherEvent {
paths: vec![path("/tmp/skills/rust/SKILL.md")],
}
);
let plugins_event = timeout(TEST_THROTTLE_INTERVAL, plugins_rx.recv()).await;
assert_eq!(plugins_event.is_err(), true);
}
#[tokio::test]
async fn non_recursive_watch_ignores_grandchildren() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills"), false);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
watcher
.send_paths_for_test(vec![path("/tmp/skills/nested/SKILL.md")])
.await;
let event = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await;
assert_eq!(event.is_err(), true);
}
#[tokio::test]
async fn ancestor_events_notify_child_watches() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), false);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
watcher.send_paths_for_test(vec![path("/tmp/skills")]).await;
let event = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("ancestor event timeout")
.expect("ancestor event");
assert_eq!(
event,
FileWatcherEvent {
paths: vec![path("/tmp/skills")],
}
);
}
#[tokio::test]
async fn spawn_event_loop_flushes_pending_changes_on_shutdown() {
let watcher = FileWatcher::noop();
let root = path("/tmp/skills");
{
let mut state = watcher.state.write().expect("state lock");
state.skills_root_ref_counts.insert(root.clone(), 1);
}
async fn spawn_event_loop_filters_non_mutating_events() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills"), true);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
let (raw_tx, raw_rx) = mpsc::unbounded_channel();
let (tx, mut rx) = broadcast::channel(8);
watcher.spawn_event_loop(raw_rx, Arc::clone(&watcher.state), tx);
watcher.spawn_event_loop_for_test(raw_rx);
raw_tx
.send(Ok(notify_event(
EventKind::Access(AccessKind::Open(AccessMode::Any)),
vec![path("/tmp/skills/SKILL.md")],
)))
.expect("send access event");
let blocked = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await;
assert_eq!(blocked.is_err(), true);
raw_tx
.send(Ok(notify_event(
EventKind::Create(CreateKind::File),
vec![root.join("a/SKILL.md")],
vec![path("/tmp/skills/SKILL.md")],
)))
.expect("send first event");
let first = timeout(Duration::from_secs(2), rx.recv())
.expect("send create event");
let event = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("first watcher event")
.expect("broadcast recv first");
.expect("create event timeout")
.expect("create event");
assert_eq!(
first,
FileWatcherEvent::SkillsChanged {
paths: vec![root.join("a/SKILL.md")]
}
);
raw_tx
.send(Ok(notify_event(
EventKind::Remove(RemoveKind::File),
vec![root.join("b/SKILL.md")],
)))
.expect("send second event");
drop(raw_tx);
let second = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("second watcher event")
.expect("broadcast recv second");
assert_eq!(
second,
FileWatcherEvent::SkillsChanged {
paths: vec![root.join("b/SKILL.md")]
event,
FileWatcherEvent {
paths: vec![path("/tmp/skills/SKILL.md")],
}
);
}
-695
View File
@@ -1,695 +0,0 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use crate::util::resolve_path;
use codex_app_server_protocol::GitSha;
use codex_protocol::protocol::GitInfo;
use futures::future::join_all;
use serde::Deserialize;
use serde::Serialize;
use tokio::process::Command;
use tokio::time::Duration as TokioDuration;
use tokio::time::timeout;
/// Return `true` if the project folder specified by the `Config` is inside a
/// Git repository.
///
/// The check walks up the directory hierarchy looking for a `.git` file or
/// directory (note `.git` can be a file that contains a `gitdir` entry). This
/// approach does **not** require the `git` binary or the `git2` crate and is
/// therefore fairly lightweight.
///
/// Note that this does **not** detect *work‑trees* created with
/// `git worktree add` where the checkout lives outside the main repository
/// directory. If you need Codex to work from such a checkout simply pass the
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
pub fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
let base = if base_dir.is_dir() {
base_dir
} else {
base_dir.parent()?
};
find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root)
}
/// Timeout for git commands to prevent freezing on large repositories
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GitDiffToRemote {
pub sha: GitSha,
pub diff: String,
}
/// Collect git repository information from the given working directory using command-line git.
/// Returns None if no git repository is found or if git operations fail.
/// Uses timeouts to prevent freezing on large repositories.
/// All git commands (except the initial repo check) run in parallel for better performance.
pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
// Check if we're in a git repository first
let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
.await?
.status
.success();
if !is_git_repo {
return None;
}
// Run all git info collection commands in parallel
let (commit_result, branch_result, url_result) = tokio::join!(
run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd),
run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd),
run_git_command_with_timeout(&["remote", "get-url", "origin"], cwd)
);
let mut git_info = GitInfo {
commit_hash: None,
branch: None,
repository_url: None,
};
// Process commit hash
if let Some(output) = commit_result
&& output.status.success()
&& let Ok(hash) = String::from_utf8(output.stdout)
{
git_info.commit_hash = Some(hash.trim().to_string());
}
// Process branch name
if let Some(output) = branch_result
&& output.status.success()
&& let Ok(branch) = String::from_utf8(output.stdout)
{
let branch = branch.trim();
if branch != "HEAD" {
git_info.branch = Some(branch.to_string());
}
}
// Process repository URL
if let Some(output) = url_result
&& output.status.success()
&& let Ok(url) = String::from_utf8(output.stdout)
{
git_info.repository_url = Some(url.trim().to_string());
}
Some(git_info)
}
/// Collect fetch remotes in a multi-root-friendly format: {"origin": "https://..."}.
pub async fn get_git_remote_urls(cwd: &Path) -> Option<BTreeMap<String, String>> {
let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
.await?
.status
.success();
if !is_git_repo {
return None;
}
get_git_remote_urls_assume_git_repo(cwd).await
}
/// Collect fetch remotes without checking whether `cwd` is in a git repo.
pub async fn get_git_remote_urls_assume_git_repo(cwd: &Path) -> Option<BTreeMap<String, String>> {
let output = run_git_command_with_timeout(&["remote", "-v"], cwd).await?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
parse_git_remote_urls(stdout.as_str())
}
/// Return the current HEAD commit hash without checking whether `cwd` is in a git repo.
pub async fn get_head_commit_hash(cwd: &Path) -> Option<String> {
let output = run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd).await?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let hash = stdout.trim();
if hash.is_empty() {
None
} else {
Some(hash.to_string())
}
}
pub async fn get_has_changes(cwd: &Path) -> Option<bool> {
let output = run_git_command_with_timeout(&["status", "--porcelain"], cwd).await?;
if !output.status.success() {
return None;
}
Some(!output.stdout.is_empty())
}
fn parse_git_remote_urls(stdout: &str) -> Option<BTreeMap<String, String>> {
let mut remotes = BTreeMap::new();
for line in stdout.lines() {
let Some(fetch_line) = line.strip_suffix(" (fetch)") else {
continue;
};
let Some((name, url_part)) = fetch_line
.split_once('\t')
.or_else(|| fetch_line.split_once(' '))
else {
continue;
};
let url = url_part.trim_start();
if !url.is_empty() {
remotes.insert(name.to_string(), url.to_string());
}
}
if remotes.is_empty() {
None
} else {
Some(remotes)
}
}
/// A minimal commit summary entry used for pickers (subject + timestamp + sha).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CommitLogEntry {
pub sha: String,
/// Unix timestamp (seconds since epoch) of the commit time (committer time).
pub timestamp: i64,
/// Single-line subject of the commit message.
pub subject: String,
}
/// Return the last `limit` commits reachable from HEAD for the current branch.
/// Each entry contains the SHA, commit timestamp (seconds), and subject line.
/// Returns an empty vector if not in a git repo or on error/timeout.
pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec<CommitLogEntry> {
// Ensure we're in a git repo first to avoid noisy errors.
let Some(out) = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd).await else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let fmt = "%H%x1f%ct%x1f%s"; // <sha> <US> <commit_time> <US> <subject>
let limit_arg = (limit > 0).then(|| limit.to_string());
let mut args: Vec<String> = vec!["log".to_string()];
if let Some(n) = &limit_arg {
args.push("-n".to_string());
args.push(n.clone());
}
args.push(format!("--pretty=format:{fmt}"));
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let Some(log_out) = run_git_command_with_timeout(&arg_refs, cwd).await else {
return Vec::new();
};
if !log_out.status.success() {
return Vec::new();
}
let text = String::from_utf8_lossy(&log_out.stdout);
let mut entries: Vec<CommitLogEntry> = Vec::new();
for line in text.lines() {
let mut parts = line.split('\u{001f}');
let sha = parts.next().unwrap_or("").trim();
let ts_s = parts.next().unwrap_or("").trim();
let subject = parts.next().unwrap_or("").trim();
if sha.is_empty() || ts_s.is_empty() {
continue;
}
let timestamp = ts_s.parse::<i64>().unwrap_or(0);
entries.push(CommitLogEntry {
sha: sha.to_string(),
timestamp,
subject: subject.to_string(),
});
}
entries
}
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
get_git_repo_root(cwd)?;
let remotes = get_git_remotes(cwd).await?;
let branches = branch_ancestry(cwd).await?;
let base_sha = find_closest_sha(cwd, &branches, &remotes).await?;
let diff = diff_against_sha(cwd, &base_sha).await?;
Some(GitDiffToRemote {
sha: base_sha,
diff,
})
}
/// Run a git command with a timeout to prevent blocking on large repositories
async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
let mut command = Command::new("git");
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(args)
.current_dir(cwd)
.kill_on_drop(true);
let result = timeout(GIT_COMMAND_TIMEOUT, command.output()).await;
match result {
Ok(Ok(output)) => Some(output),
_ => None, // Timeout or error
}
}
async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
let output = run_git_command_with_timeout(&["remote"], cwd).await?;
if !output.status.success() {
return None;
}
let mut remotes: Vec<String> = String::from_utf8(output.stdout)
.ok()?
.lines()
.map(str::to_string)
.collect();
if let Some(pos) = remotes.iter().position(|r| r == "origin") {
let origin = remotes.remove(pos);
remotes.insert(0, origin);
}
Some(remotes)
}
/// Attempt to determine the repository's default branch name.
///
/// Preference order:
/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
/// 3) Local fallback to existing `main` or `master` if present
async fn get_default_branch(cwd: &Path) -> Option<String> {
// Prefer the first remote (with origin prioritized)
let remotes = get_git_remotes(cwd).await.unwrap_or_default();
for remote in remotes {
// Try symbolic-ref, which returns something like: refs/remotes/origin/main
if let Some(symref_output) = run_git_command_with_timeout(
&[
"symbolic-ref",
"--quiet",
&format!("refs/remotes/{remote}/HEAD"),
],
cwd,
)
.await
&& symref_output.status.success()
&& let Ok(sym) = String::from_utf8(symref_output.stdout)
{
let trimmed = sym.trim();
if let Some((_, name)) = trimmed.rsplit_once('/') {
return Some(name.to_string());
}
}
// Fall back to parsing `git remote show <remote>` output
if let Some(show_output) =
run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
&& show_output.status.success()
&& let Ok(text) = String::from_utf8(show_output.stdout)
{
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("HEAD branch:") {
let name = rest.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
}
// No remote-derived default; try common local defaults if they exist
get_default_branch_local(cwd).await
}
/// Determine the repository's default branch name, if available.
///
/// This inspects remote configuration first (including the symbolic `HEAD`
/// reference) and falls back to common local defaults such as `main` or
/// `master`. Returns `None` when the information cannot be determined, for
/// example when the current directory is not inside a Git repository.
pub async fn default_branch_name(cwd: &Path) -> Option<String> {
get_default_branch(cwd).await
}
/// Attempt to determine the repository's default branch name from local branches.
async fn get_default_branch_local(cwd: &Path) -> Option<String> {
for candidate in ["main", "master"] {
if let Some(verify) = run_git_command_with_timeout(
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{candidate}"),
],
cwd,
)
.await
&& verify.status.success()
{
return Some(candidate.to_string());
}
}
None
}
/// Build an ancestry of branches starting at the current branch and ending at the
/// repository's default branch (if determinable)..
async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
// Discover current branch (ignore detached HEAD by treating it as None)
let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
.await
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string())
.filter(|s| s != "HEAD");
// Discover default branch
let default_branch = get_default_branch(cwd).await;
let mut ancestry: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
if let Some(cb) = current_branch.clone() {
seen.insert(cb.clone());
ancestry.push(cb);
}
if let Some(db) = default_branch
&& !seen.contains(&db)
{
seen.insert(db.clone());
ancestry.push(db);
}
// Expand candidates: include any remote branches that already contain HEAD.
// This addresses cases where we're on a new local-only branch forked from a
// remote branch that isn't the repository default. We prioritize remotes in
// the order returned by get_git_remotes (origin first).
let remotes = get_git_remotes(cwd).await.unwrap_or_default();
for remote in remotes {
if let Some(output) = run_git_command_with_timeout(
&[
"for-each-ref",
"--format=%(refname:short)",
"--contains=HEAD",
&format!("refs/remotes/{remote}"),
],
cwd,
)
.await
&& output.status.success()
&& let Ok(text) = String::from_utf8(output.stdout)
{
for line in text.lines() {
let short = line.trim();
// Expect format like: "origin/feature"; extract the branch path after "remote/"
if let Some(stripped) = short.strip_prefix(&format!("{remote}/"))
&& !stripped.is_empty()
&& !seen.contains(stripped)
{
seen.insert(stripped.to_string());
ancestry.push(stripped.to_string());
}
}
}
}
// Ensure we return Some vector, even if empty, to allow caller logic to proceed
Some(ancestry)
}
// Helper for a single branch: return the remote SHA if present on any remote
// and the distance (commits ahead of HEAD) for that branch. The first item is
// None if the branch is not present on any remote. Returns None if distance
// could not be computed due to git errors/timeouts.
async fn branch_remote_and_distance(
cwd: &Path,
branch: &str,
remotes: &[String],
) -> Option<(Option<GitSha>, usize)> {
// Try to find the first remote ref that exists for this branch (origin prioritized by caller).
let mut found_remote_sha: Option<GitSha> = None;
let mut found_remote_ref: Option<String> = None;
for remote in remotes {
let remote_ref = format!("refs/remotes/{remote}/{branch}");
let Some(verify_output) =
run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", &remote_ref], cwd)
.await
else {
// Mirror previous behavior: if the verify call times out/fails at the process level,
// treat the entire branch as unusable.
return None;
};
if !verify_output.status.success() {
continue;
}
let Ok(sha) = String::from_utf8(verify_output.stdout) else {
// Mirror previous behavior and skip the entire branch on parse failure.
return None;
};
found_remote_sha = Some(GitSha::new(sha.trim()));
found_remote_ref = Some(remote_ref);
break;
}
// Compute distance as the number of commits HEAD is ahead of the branch.
// Prefer local branch name if it exists; otherwise fall back to the remote ref (if any).
let count_output = if let Some(local_count) =
run_git_command_with_timeout(&["rev-list", "--count", &format!("{branch}..HEAD")], cwd)
.await
{
if local_count.status.success() {
local_count
} else if let Some(remote_ref) = &found_remote_ref {
match run_git_command_with_timeout(
&["rev-list", "--count", &format!("{remote_ref}..HEAD")],
cwd,
)
.await
{
Some(remote_count) => remote_count,
None => return None,
}
} else {
return None;
}
} else if let Some(remote_ref) = &found_remote_ref {
match run_git_command_with_timeout(
&["rev-list", "--count", &format!("{remote_ref}..HEAD")],
cwd,
)
.await
{
Some(remote_count) => remote_count,
None => return None,
}
} else {
return None;
};
if !count_output.status.success() {
return None;
}
let Ok(distance_str) = String::from_utf8(count_output.stdout) else {
return None;
};
let Ok(distance) = distance_str.trim().parse::<usize>() else {
return None;
};
Some((found_remote_sha, distance))
}
// Finds the closest sha that exist on any of branches and also exists on any of the remotes.
async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
// A sha and how many commits away from HEAD it is.
let mut closest_sha: Option<(GitSha, usize)> = None;
for branch in branches {
let Some((maybe_remote_sha, distance)) =
branch_remote_and_distance(cwd, branch, remotes).await
else {
continue;
};
let Some(remote_sha) = maybe_remote_sha else {
// Preserve existing behavior: skip branches that are not present on a remote.
continue;
};
match &closest_sha {
None => closest_sha = Some((remote_sha, distance)),
Some((_, best_distance)) if distance < *best_distance => {
closest_sha = Some((remote_sha, distance));
}
_ => {}
}
}
closest_sha.map(|(sha, _)| sha)
}
async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
let output =
run_git_command_with_timeout(&["diff", "--no-textconv", "--no-ext-diff", &sha.0], cwd)
.await?;
// 0 is success and no diff.
// 1 is success but there is a diff.
let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
if !exit_ok {
return None;
}
let mut diff = String::from_utf8(output.stdout).ok()?;
if let Some(untracked_output) =
run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await
&& untracked_output.status.success()
{
let untracked: Vec<String> = String::from_utf8(untracked_output.stdout)
.ok()?
.lines()
.map(str::to_string)
.filter(|s| !s.is_empty())
.collect();
if !untracked.is_empty() {
// Use platform-appropriate null device and guard paths with `--`.
let null_device: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
let futures_iter = untracked.into_iter().map(|file| async move {
let file_owned = file;
let args_vec: Vec<&str> = vec![
"diff",
"--no-textconv",
"--no-ext-diff",
"--binary",
"--no-index",
// -- ensures that filenames that start with - are not treated as options.
"--",
null_device,
&file_owned,
];
run_git_command_with_timeout(&args_vec, cwd).await
});
let results = join_all(futures_iter).await;
for extra in results.into_iter().flatten() {
if extra.status.code().is_some_and(|c| c == 0 || c == 1)
&& let Ok(s) = String::from_utf8(extra.stdout)
{
diff.push_str(&s);
}
}
}
}
Some(diff)
}
/// Resolve the path that should be used for trust checks. Similar to
/// `[get_git_repo_root]`, but resolves to the root of the main
/// repository. Handles worktrees via filesystem inspection without invoking
/// the `git` executable.
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
let (repo_root, dot_git) = find_ancestor_git_entry(base)?;
if dot_git.is_dir() {
return Some(canonicalize_or_raw(repo_root));
}
let git_dir_s = std::fs::read_to_string(&dot_git).ok()?;
let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim();
if git_dir_rel.is_empty() {
return None;
}
let git_dir_path = canonicalize_or_raw(resolve_path(&repo_root, &PathBuf::from(git_dir_rel)));
let worktrees_dir = git_dir_path.parent()?;
if worktrees_dir.file_name() != Some(OsStr::new("worktrees")) {
return None;
}
let common_dir = worktrees_dir.parent()?;
let main_repo_root = common_dir.parent()?;
Some(canonicalize_or_raw(main_repo_root.to_path_buf()))
}
fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> {
let mut dir = base_dir.to_path_buf();
loop {
let dot_git = dir.join(".git");
if dot_git.exists() {
return Some((dir, dot_git));
}
// Pop one component (go up one directory). `pop` returns false when
// we have reached the filesystem root.
if !dir.pop() {
break;
}
}
None
}
fn canonicalize_or_raw(path: PathBuf) -> PathBuf {
std::fs::canonicalize(&path).unwrap_or(path)
}
/// Returns a list of local git branches.
/// Includes the default branch at the beginning of the list, if it exists.
pub async fn local_git_branches(cwd: &Path) -> Vec<String> {
let mut branches: Vec<String> = if let Some(out) =
run_git_command_with_timeout(&["branch", "--format=%(refname:short)"], cwd).await
&& out.status.success()
{
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
Vec::new()
};
branches.sort_unstable();
if let Some(base) = get_default_branch_local(cwd).await
&& let Some(pos) = branches.iter().position(|name| name == &base)
{
let base_branch = branches.remove(pos);
branches.insert(0, base_branch);
}
branches
}
/// Returns the current checked out branch name.
pub async fn current_branch_name(cwd: &Path) -> Option<String> {
let out = run_git_command_with_timeout(&["branch", "--show-current"], cwd).await?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout)
.ok()
.map(|s| s.trim().to_string())
.filter(|name| !name.is_empty())
}
#[cfg(test)]
#[path = "git_info_tests.rs"]
mod tests;
+10 -4
View File
@@ -1,9 +1,15 @@
use super::*;
use codex_git_utils::GitInfo;
use codex_git_utils::GitSha;
use codex_git_utils::collect_git_info;
use codex_git_utils::get_has_changes;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::recent_commits;
use codex_git_utils::resolve_root_git_project_for_trust;
use core_test_support::skip_if_sandbox;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::process::Command;
// Helper function to create a test git repository
async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
@@ -191,7 +197,7 @@ async fn test_collect_git_info_git_repository() {
// Should have commit hash
assert!(git_info.commit_hash.is_some());
let commit_hash = git_info.commit_hash.unwrap();
let commit_hash = git_info.commit_hash.unwrap().0;
assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters
assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));
@@ -558,7 +564,7 @@ async fn test_get_git_working_tree_state_unpushed_commit() {
#[test]
fn test_git_info_serialization() {
let git_info = GitInfo {
commit_hash: Some("abc123def456".to_string()),
commit_hash: Some(GitSha::new("abc123def456")),
branch: Some("main".to_string()),
repository_url: Some("https://github.com/example/repo.git".to_string()),
};
+2 -109
View File
@@ -5,6 +5,8 @@ use crate::spawn::spawn_child_async;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_sandboxing::landlock::allow_network_for_proxy;
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -59,112 +61,3 @@ where
})
.await
}
pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
// When managed network requirements are active, request proxy-only
// networking from the Linux sandbox helper. Without managed requirements,
// preserve existing behavior.
enforce_managed_network
}
/// Converts the sandbox policies into the CLI invocation for
/// `codex-linux-sandbox`.
///
/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after
/// parsing these arguments. Policy JSON flags are emitted before helper feature
/// flags so the argv order matches the helper's CLI shape. See
/// `docs/linux_sandbox.md` for the Linux semantics.
#[allow(clippy::too_many_arguments)]
pub fn create_linux_sandbox_command_args_for_policies(
command: Vec<String>,
command_cwd: &Path,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
allow_network_for_proxy: bool,
) -> Vec<String> {
let sandbox_policy_json = serde_json::to_string(sandbox_policy)
.unwrap_or_else(|err| panic!("failed to serialize sandbox policy: {err}"));
let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy)
.unwrap_or_else(|err| panic!("failed to serialize filesystem sandbox policy: {err}"));
let network_policy_json = serde_json::to_string(&network_sandbox_policy)
.unwrap_or_else(|err| panic!("failed to serialize network sandbox policy: {err}"));
let sandbox_policy_cwd = sandbox_policy_cwd
.to_str()
.unwrap_or_else(|| panic!("cwd must be valid UTF-8"))
.to_string();
let command_cwd = command_cwd
.to_str()
.unwrap_or_else(|| panic!("command cwd must be valid UTF-8"))
.to_string();
let mut linux_cmd: Vec<String> = vec![
"--sandbox-policy-cwd".to_string(),
sandbox_policy_cwd,
"--command-cwd".to_string(),
command_cwd,
"--sandbox-policy".to_string(),
sandbox_policy_json,
"--file-system-sandbox-policy".to_string(),
file_system_policy_json,
"--network-sandbox-policy".to_string(),
network_policy_json,
];
if use_legacy_landlock {
linux_cmd.push("--use-legacy-landlock".to_string());
}
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
}
linux_cmd.push("--".to_string());
linux_cmd.extend(command);
linux_cmd
}
/// Converts the sandbox cwd and execution options into the CLI invocation for
/// `codex-linux-sandbox`.
#[cfg(test)]
pub(crate) fn create_linux_sandbox_command_args(
command: Vec<String>,
command_cwd: &Path,
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
allow_network_for_proxy: bool,
) -> Vec<String> {
let command_cwd = command_cwd
.to_str()
.unwrap_or_else(|| panic!("command cwd must be valid UTF-8"))
.to_string();
let sandbox_policy_cwd = sandbox_policy_cwd
.to_str()
.unwrap_or_else(|| panic!("cwd must be valid UTF-8"))
.to_string();
let mut linux_cmd: Vec<String> = vec![
"--sandbox-policy-cwd".to_string(),
sandbox_policy_cwd,
"--command-cwd".to_string(),
command_cwd,
];
if use_legacy_landlock {
linux_cmd.push("--use-legacy-landlock".to_string());
}
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
}
// Separator so that command arguments starting with `-` are not parsed as
// options of the helper itself.
linux_cmd.push("--".to_string());
// Append the original tool command.
linux_cmd.extend(command);
linux_cmd
}
#[cfg(test)]
#[path = "landlock_tests.rs"]
mod tests;
-78
View File
@@ -1,78 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn legacy_landlock_flag_is_included_when_requested() {
let command = vec!["/bin/true".to_string()];
let command_cwd = Path::new("/tmp/link");
let cwd = Path::new("/tmp");
let default_bwrap =
create_linux_sandbox_command_args(command.clone(), command_cwd, cwd, false, false);
assert_eq!(
default_bwrap.contains(&"--use-legacy-landlock".to_string()),
false
);
let legacy_landlock = create_linux_sandbox_command_args(command, command_cwd, cwd, true, false);
assert_eq!(
legacy_landlock.contains(&"--use-legacy-landlock".to_string()),
true
);
}
#[test]
fn proxy_flag_is_included_when_requested() {
let command = vec!["/bin/true".to_string()];
let command_cwd = Path::new("/tmp/link");
let cwd = Path::new("/tmp");
let args = create_linux_sandbox_command_args(command, command_cwd, cwd, true, true);
assert_eq!(
args.contains(&"--allow-network-for-proxy".to_string()),
true
);
}
#[test]
fn split_policy_flags_are_included() {
let command = vec!["/bin/true".to_string()];
let command_cwd = Path::new("/tmp/link");
let cwd = Path::new("/tmp");
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let args = create_linux_sandbox_command_args_for_policies(
command,
command_cwd,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
cwd,
true,
false,
);
assert_eq!(
args.windows(2)
.any(|window| { window[0] == "--file-system-sandbox-policy" && !window[1].is_empty() }),
true
);
assert_eq!(
args.windows(2)
.any(|window| window[0] == "--network-sandbox-policy" && window[1] == "\"restricted\""),
true
);
assert_eq!(
args.windows(2)
.any(|window| window[0] == "--command-cwd" && window[1] == "/tmp/link"),
true
);
}
#[test]
fn proxy_network_requires_managed_requirements() {
assert_eq!(allow_network_for_proxy(false), false);
assert_eq!(allow_network_for_proxy(true), true);
}
+6 -4
View File
@@ -39,9 +39,10 @@ pub mod exec;
pub mod exec_env;
mod exec_policy;
pub mod external_agent_config;
mod file_watcher;
pub mod file_watcher;
mod flags;
pub mod git_info;
#[cfg(test)]
mod git_info_tests;
mod guardian;
mod hook_runtime;
pub mod instructions;
@@ -72,6 +73,7 @@ pub mod sandboxing;
mod session_prefix;
mod session_startup_prewarm;
mod shell_detect;
mod skills_watcher;
mod stream_events_utils;
pub mod test_support;
mod text_encoding;
@@ -93,10 +95,10 @@ mod event_mapping;
mod response_debug_context;
pub mod review_format;
pub mod review_prompts;
mod seatbelt_permissions;
mod thread_manager;
pub mod web_search;
pub mod windows_sandbox_read_grants;
pub use thread_manager::ForkSnapshot;
pub use thread_manager::NewThread;
pub use thread_manager::ThreadManager;
#[deprecated(note = "use ThreadManager")]
@@ -173,6 +175,7 @@ pub use client_common::Prompt;
pub use client_common::REVIEW_PROMPT;
pub use client_common::ResponseEvent;
pub use client_common::ResponseStream;
pub use codex_sandboxing::get_platform_sandbox;
pub use compact::content_items_to_text;
pub use event_mapping::parse_turn_item;
pub use exec_policy::ExecPolicyError;
@@ -180,7 +183,6 @@ pub use exec_policy::check_execpolicy_for_warnings;
pub use exec_policy::format_exec_policy_error_with_source;
pub use exec_policy::load_exec_policy;
pub use file_watcher::FileWatcherEvent;
pub use safety::get_platform_sandbox;
pub use tools::spec::parse_tool_input_schema;
pub use turn_metadata::build_turn_metadata_header;
pub mod compact;
+2 -1
View File
@@ -39,7 +39,8 @@ pub(crate) fn list_tool_suggest_discoverable_plugins(
.collect::<HashSet<_>>();
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.context("failed to list plugin marketplaces for tool suggestions")?;
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let Some(curated_marketplace) = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
+26 -7
View File
@@ -4,6 +4,7 @@ use super::load_plugin_manifest;
use super::manifest::PluginManifestInterface;
use super::marketplace::MarketplaceError;
use super::marketplace::MarketplaceInterface;
use super::marketplace::MarketplaceListError;
use super::marketplace::MarketplacePluginAuthPolicy;
use super::marketplace::MarketplacePluginPolicy;
use super::marketplace::MarketplacePluginSource;
@@ -178,6 +179,12 @@ pub struct ConfiguredMarketplacePlugin {
pub enabled: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConfiguredMarketplaceListOutcome {
pub marketplaces: Vec<ConfiguredMarketplace>,
pub errors: Vec<MarketplaceListError>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
pub config_name: String,
@@ -996,16 +1003,16 @@ impl PluginsManager {
&self,
config: &Config,
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<ConfiguredMarketplace>, MarketplaceError> {
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
return Ok(ConfiguredMarketplaceListOutcome::default());
}
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?;
let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?;
let mut seen_plugin_keys = HashSet::new();
Ok(marketplaces
let marketplaces = marketplace_outcome
.marketplaces
.into_iter()
.filter_map(|marketplace| {
let marketplace_name = marketplace.name.clone();
@@ -1043,7 +1050,12 @@ impl PluginsManager {
plugins,
})
})
.collect())
.collect();
Ok(ConfiguredMarketplaceListOutcome {
marketplaces,
errors: marketplace_outcome.errors,
})
}
pub fn read_plugin_for_config(
@@ -1550,7 +1562,14 @@ fn load_plugin(
};
let manifest_paths = &manifest.paths;
loaded_plugin.manifest_name = Some(manifest.name.clone());
loaded_plugin.manifest_name = manifest
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.or_else(|| Some(manifest.name.clone()));
loaded_plugin.manifest_description = manifest.description.clone();
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths);
let resolved_skills = load_plugin_skills(
+13 -6
View File
@@ -1035,7 +1035,8 @@ enabled = false
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
@@ -1130,7 +1131,8 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(marketplaces, Vec::new());
}
@@ -1177,7 +1179,8 @@ plugins = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
@@ -1382,7 +1385,8 @@ plugins = true
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.unwrap();
.unwrap()
.marketplaces;
let curated_marketplace = marketplaces
.into_iter()
@@ -1487,7 +1491,8 @@ enabled = false
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
],
)
.unwrap();
.unwrap()
.marketplaces;
let repo_a_marketplace = marketplaces
.iter()
@@ -1590,7 +1595,8 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
@@ -1733,6 +1739,7 @@ enabled = true
let curated_marketplace = manager
.list_marketplaces_for_config(&synced_config, &[])
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
.unwrap();
+22 -6
View File
@@ -2,9 +2,9 @@ use super::PluginManifestInterface;
use super::load_plugin_manifest;
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_git_utils::get_git_repo_root;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
@@ -33,6 +33,18 @@ pub struct Marketplace {
pub plugins: Vec<MarketplacePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceListError {
pub path: AbsolutePathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MarketplaceListOutcome {
pub marketplaces: Vec<Marketplace>,
pub errors: Vec<MarketplaceListError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceInterface {
pub display_name: Option<String>,
@@ -195,7 +207,7 @@ pub fn resolve_marketplace_plugin(
pub fn list_marketplaces(
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<Marketplace>, MarketplaceError> {
) -> Result<MarketplaceListOutcome, MarketplaceError> {
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
}
@@ -246,23 +258,27 @@ pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Ma
fn list_marketplaces_with_home(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Result<Vec<Marketplace>, MarketplaceError> {
let mut marketplaces = Vec::new();
) -> Result<MarketplaceListOutcome, MarketplaceError> {
let mut outcome = MarketplaceListOutcome::default();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
match load_marketplace(&marketplace_path) {
Ok(marketplace) => marketplaces.push(marketplace),
Ok(marketplace) => outcome.marketplaces.push(marketplace),
Err(err) => {
warn!(
path = %marketplace_path.display(),
error = %err,
"skipping marketplace that failed to load"
);
outcome.errors.push(MarketplaceListError {
path: marketplace_path,
message: err.to_string(),
});
}
}
}
Ok(marketplaces)
Ok(outcome)
}
fn discover_marketplace_paths_from_roots(
+69 -8
View File
@@ -130,7 +130,8 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
Some(&home_root),
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
@@ -254,7 +255,8 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
Some(&home_root),
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
@@ -342,7 +344,8 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
],
None,
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
@@ -396,7 +399,8 @@ fn list_marketplaces_reads_marketplace_display_name() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].interface,
@@ -456,12 +460,66 @@ fn list_marketplaces_skips_marketplaces_that_fail_to_load() {
],
None,
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, "valid-marketplace");
}
#[test]
fn list_marketplaces_reports_marketplace_load_errors() {
let tmp = tempdir().unwrap();
let valid_repo_root = tmp.path().join("valid-repo");
let invalid_repo_root = tmp.path().join("invalid-repo");
fs::create_dir_all(valid_repo_root.join(".git")).unwrap();
fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(invalid_repo_root.join(".git")).unwrap();
fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap();
fs::write(
valid_repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "valid-marketplace",
"plugins": [
{
"name": "valid-plugin",
"source": {
"source": "local",
"path": "./plugin"
}
}
]
}"#,
)
.unwrap();
let invalid_marketplace_path =
AbsolutePathBuf::try_from(invalid_repo_root.join(".agents/plugins/marketplace.json"))
.unwrap();
fs::write(invalid_marketplace_path.as_path(), "{not json").unwrap();
let outcome = list_marketplaces_with_home(
&[
AbsolutePathBuf::try_from(valid_repo_root).unwrap(),
AbsolutePathBuf::try_from(invalid_repo_root).unwrap(),
],
None,
)
.unwrap();
assert_eq!(outcome.marketplaces.len(), 1);
assert_eq!(outcome.marketplaces[0].name, "valid-marketplace");
assert_eq!(outcome.errors.len(), 1);
assert_eq!(outcome.errors[0].path, invalid_marketplace_path);
assert!(
outcome.errors[0]
.message
.contains("invalid marketplace file"),
"unexpected errors: {:?}",
outcome.errors
);
}
#[test]
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
let tmp = tempdir().unwrap();
@@ -510,7 +568,8 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].policy.installation,
@@ -577,7 +636,8 @@ fn list_marketplaces_ignores_legacy_top_level_policy_fields() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].policy.installation,
@@ -632,7 +692,8 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].interface,
+2
View File
@@ -15,6 +15,7 @@ pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
@@ -40,6 +41,7 @@ pub use manifest::PluginManifestInterface;
pub(crate) use manifest::PluginManifestPaths;
pub(crate) use manifest::load_plugin_manifest;
pub use marketplace::MarketplaceError;
pub use marketplace::MarketplaceListError;
pub use marketplace::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginPolicy;
+1 -1
View File
@@ -1,10 +1,10 @@
use crate::codex::Session;
use crate::compact::content_items_to_text;
use crate::event_mapping::is_contextual_user_message_content;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::truncate::TruncationPolicy;
use crate::truncate::truncate_text;
use chrono::Utc;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::models::ResponseItem;
use codex_state::SortKey;
use codex_state::ThreadMetadata;
@@ -1,199 +0,0 @@
; macOS platform defaults included via `ReadOnlyAccess::Restricted::include_platform_defaults`
; Read access to standard system paths
(allow file-read* file-test-existence
(subpath "/Library/Apple")
(subpath "/Library/Filesystems/NetFSPlugins")
(subpath "/Library/Preferences/Logging")
(subpath "/private/var/db/DarwinDirectory/local/recordStore.data")
(subpath "/private/var/db/timezone")
(subpath "/usr/lib")
(subpath "/usr/share")
(subpath "/Library/Preferences")
(subpath "/var/db")
(subpath "/private/var/db"))
; Map system frameworks + dylibs for loader.
(allow file-map-executable
(subpath "/Library/Apple/System/Library/Frameworks")
(subpath "/Library/Apple/System/Library/PrivateFrameworks")
(subpath "/Library/Apple/usr/lib")
(subpath "/System/Library/Extensions")
(subpath "/System/Library/Frameworks")
(subpath "/System/Library/PrivateFrameworks")
(subpath "/System/Library/SubFrameworks")
(subpath "/System/iOSSupport/System/Library/Frameworks")
(subpath "/System/iOSSupport/System/Library/PrivateFrameworks")
(subpath "/System/iOSSupport/System/Library/SubFrameworks")
(subpath "/usr/lib"))
; System Framework and AppKit resources
(allow file-read* file-test-existence
(subpath "/Library/Apple/System/Library/Frameworks")
(subpath "/Library/Apple/System/Library/PrivateFrameworks")
(subpath "/Library/Apple/usr/lib")
(subpath "/System/Library/Frameworks")
(subpath "/System/Library/PrivateFrameworks")
(subpath "/System/Library/SubFrameworks")
(subpath "/System/iOSSupport/System/Library/Frameworks")
(subpath "/System/iOSSupport/System/Library/PrivateFrameworks")
(subpath "/System/iOSSupport/System/Library/SubFrameworks")
(subpath "/usr/lib"))
; Allow guarded vnodes.
(allow system-mac-syscall (mac-policy-name "vnguard"))
; Determine whether a container is expected.
(allow system-mac-syscall
(require-all
(mac-policy-name "Sandbox")
(mac-syscall-number 67)))
; Allow resolution of standard system symlinks.
(allow file-read-metadata file-test-existence
(literal "/etc")
(literal "/tmp")
(literal "/var")
(literal "/private/etc/localtime"))
; Allow stat'ing of firmlink parent path components.
(allow file-read-metadata file-test-existence
(path-ancestors "/System/Volumes/Data/private"))
; Allow processes to get their current working directory.
(allow file-read* file-test-existence
(literal "/"))
; Allow FSIOC_CAS_BSDFLAGS as alternate chflags.
(allow system-fsctl (fsctl-command FSIOC_CAS_BSDFLAGS))
; Allow access to standard special files.
(allow file-read* file-test-existence
(literal "/dev/autofs_nowait")
(literal "/dev/random")
(literal "/dev/urandom")
(literal "/private/etc/master.passwd")
(literal "/private/etc/passwd")
(literal "/private/etc/protocols")
(literal "/private/etc/services"))
; Allow null/zero read/write.
(allow file-read* file-test-existence file-write-data
(literal "/dev/null")
(literal "/dev/zero"))
; Allow read/write access to the file descriptors.
(allow file-read-data file-test-existence file-write-data
(subpath "/dev/fd"))
; Provide access to debugger helpers.
(allow file-read* file-test-existence file-write-data file-ioctl
(literal "/dev/dtracehelper"))
; Scratch space so tools can create temp files.
(allow file-read* file-test-existence file-write* (subpath "/tmp"))
(allow file-read* file-write* (subpath "/private/tmp"))
(allow file-read* file-write* (subpath "/var/tmp"))
(allow file-read* file-write* (subpath "/private/var/tmp"))
; Allow reading standard config directories.
(allow file-read* (subpath "/etc"))
(allow file-read* (subpath "/private/etc"))
(allow file-read* file-test-existence
(literal "/System/Library/CoreServices")
(literal "/System/Library/CoreServices/.SystemVersionPlatform.plist")
(literal "/System/Library/CoreServices/SystemVersion.plist"))
; Some processes read /var metadata during startup.
(allow file-read-metadata (subpath "/var"))
(allow file-read-metadata (subpath "/private/var"))
; IOKit access for root domain services.
(allow iokit-open
(iokit-registry-entry-class "RootDomainUserClient"))
; macOS Standard library queries opendirectoryd at startup
(allow mach-lookup (global-name "com.apple.system.opendirectoryd.libinfo"))
; Allow IPC to analytics, logging, trust, and other system agents.
(allow mach-lookup
(global-name "com.apple.analyticsd")
(global-name "com.apple.analyticsd.messagetracer")
(global-name "com.apple.appsleep")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.cfprefsd.agent")
(global-name "com.apple.cfprefsd.daemon")
(global-name "com.apple.diagnosticd")
(global-name "com.apple.dt.automationmode.reader")
(global-name "com.apple.espd")
(global-name "com.apple.logd")
(global-name "com.apple.logd.events")
(global-name "com.apple.runningboard")
(global-name "com.apple.secinitd")
(global-name "com.apple.system.DirectoryService.libinfo_v1")
(global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.xpc.activity.unmanaged")
(local-name "com.apple.cfprefsd.agent"))
; Allow IPC to the syslog socket for logging.
(allow network-outbound (literal "/private/var/run/syslog"))
; macOS Notifications
(allow ipc-posix-shm-read*
(ipc-posix-name "apple.shm.notification_center"))
; Regulatory domain support.
(allow file-read*
(literal "/private/var/db/eligibilityd/eligibility.plist"))
; Audio and power management services.
(allow mach-lookup (global-name "com.apple.audio.audiohald"))
(allow mach-lookup (global-name "com.apple.audio.AudioComponentRegistrar"))
(allow mach-lookup (global-name "com.apple.PowerManagement.control"))
; Allow reading the minimum system runtime so exec works.
(allow file-read-data (subpath "/bin"))
(allow file-read-metadata (subpath "/bin"))
(allow file-read-data (subpath "/sbin"))
(allow file-read-metadata (subpath "/sbin"))
(allow file-read-data (subpath "/usr/bin"))
(allow file-read-metadata (subpath "/usr/bin"))
(allow file-read-data (subpath "/usr/sbin"))
(allow file-read-metadata (subpath "/usr/sbin"))
(allow file-read-data (subpath "/usr/libexec"))
(allow file-read-metadata (subpath "/usr/libexec"))
(allow file-read* (subpath "/Library/Preferences"))
(allow file-read* (subpath "/opt/homebrew/lib"))
(allow file-read* (subpath "/usr/local/lib"))
(allow file-read* (subpath "/Applications"))
; Terminal basics and device handles.
(allow file-read* (regex "^/dev/fd/(0|1|2)$"))
(allow file-write* (regex "^/dev/fd/(1|2)$"))
(allow file-read* file-write* (literal "/dev/null"))
(allow file-read* file-write* (literal "/dev/tty"))
(allow file-read-metadata (literal "/dev"))
(allow file-read-metadata (regex "^/dev/.*$"))
(allow file-read-metadata (literal "/dev/stdin"))
(allow file-read-metadata (literal "/dev/stdout"))
(allow file-read-metadata (literal "/dev/stderr"))
(allow file-read-metadata (regex "^/dev/tty[^/]*$"))
(allow file-read-metadata (regex "^/dev/pty[^/]*$"))
(allow file-read* file-write* (regex "^/dev/ttys[0-9]+$"))
(allow file-read* file-write* (literal "/dev/ptmx"))
(allow file-ioctl (regex "^/dev/ttys[0-9]+$"))
; Allow metadata traversal for firmlink parents.
(allow file-read-metadata (literal "/System/Volumes") (vnode-type DIRECTORY))
(allow file-read-metadata (literal "/System/Volumes/Data") (vnode-type DIRECTORY))
(allow file-read-metadata (literal "/System/Volumes/Data/Users") (vnode-type DIRECTORY))
; App sandbox extensions
(allow file-read* (extension "com.apple.app-sandbox.read"))
(allow file-read* file-write* (extension "com.apple.app-sandbox.read-write"))
+1 -1
View File
@@ -1,4 +1,4 @@
use codex_git::merge_base_with_head;
use codex_git_utils::merge_base_with_head;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use std::path::Path;
+1 -1
View File
@@ -1039,7 +1039,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
summary.git_sha = session_meta_line
.git
.as_ref()
.and_then(|git| git.commit_hash.clone());
.and_then(|git| git.commit_hash.as_ref().map(|sha| sha.0.clone()));
summary.git_origin_url = session_meta_line
.git
.as_ref()
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) fn builder_from_session_meta(
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
builder.approval_mode = AskForApproval::OnRequest;
if let Some(git) = session_meta.git.as_ref() {
builder.git_sha = git.commit_hash.clone();
builder.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone());
builder.git_branch = git.branch.clone();
builder.git_origin_url = git.repository_url.clone();
}
+1 -1
View File
@@ -245,7 +245,7 @@ async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_f
"2026-01-27T12:34:56Z",
thread_uuid,
Some(GitInfo {
commit_hash: Some("rollout-sha".to_string()),
commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")),
branch: Some("rollout-branch".to_string()),
repository_url: Some("git@example.com:openai/codex.git".to_string()),
}),
+7 -2
View File
@@ -41,13 +41,14 @@ use super::policy::EventPersistenceMode;
use super::policy::is_persisted_response_item;
use crate::config::Config;
use crate::default_client::originator;
use crate::git_info::collect_git_info;
use crate::path_utils;
use crate::state_db;
use crate::state_db::StateDbHandle;
use crate::truncate::TruncationPolicy;
use crate::truncate::truncate_text;
use codex_git_utils::collect_git_info;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo as ProtocolGitInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
@@ -846,7 +847,11 @@ async fn write_session_meta(
default_provider: &str,
generate_memories: bool,
) -> std::io::Result<()> {
let git_info = collect_git_info(cwd).await;
let git_info = collect_git_info(cwd).await.map(|info| ProtocolGitInfo {
commit_hash: info.commit_hash,
branch: info.branch,
repository_url: info.repository_url,
});
let session_meta_line = SessionMetaLine {
meta: session_meta,
git: git_info,
+4 -4
View File
@@ -46,8 +46,8 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec<us
/// a prefix that excludes the first user message and everything after it).
///
/// If `n_from_start` is `usize::MAX`, this returns the full rollout (no truncation).
/// If fewer than or equal to `n_from_start` user messages exist, this returns an empty
/// vector (out of range).
/// If fewer than or equal to `n_from_start` user messages exist, this returns the full
/// rollout unchanged.
pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
items: &[RolloutItem],
n_from_start: usize,
@@ -58,9 +58,9 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
let user_positions = user_message_positions_in_rollout(items);
// If fewer than or equal to n user messages exist, treat as empty (out of range).
// If fewer than or equal to n user messages exist, keep the full rollout.
if user_positions.len() <= n_from_start {
return Vec::new();
return items.to_vec();
}
// Cut strictly before the nth user message (do not keep the nth itself).
@@ -1,6 +1,5 @@
use super::*;
use crate::codex::make_session_and_context;
use assert_matches::assert_matches;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::protocol::ThreadRolledBackEvent;
@@ -74,7 +73,10 @@ fn truncates_rollout_from_start_before_nth_user_only() {
);
let truncated2 = truncate_rollout_before_nth_user_message_from_start(&rollout, 2);
assert_matches!(truncated2.as_slice(), []);
assert_eq!(
serde_json::to_value(&truncated2).unwrap(),
serde_json::to_value(&rollout).unwrap()
);
}
#[test]
+5 -22
View File
@@ -2,16 +2,15 @@ use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use crate::exec::SandboxType;
use crate::util::resolve_path;
use crate::protocol::AskForApproval;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::SandboxPolicy;
use crate::util::resolve_path;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_sandboxing::SandboxType;
use codex_sandboxing::get_platform_sandbox;
#[derive(Debug, PartialEq)]
pub enum SafetyCheck {
@@ -106,22 +105,6 @@ pub fn assess_patch_safety(
}
}
pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option<SandboxType> {
if cfg!(target_os = "macos") {
Some(SandboxType::MacosSeatbelt)
} else if cfg!(target_os = "linux") {
Some(SandboxType::LinuxSeccomp)
} else if cfg!(target_os = "windows") {
if windows_sandbox_enabled {
Some(SandboxType::WindowsRestrictedToken)
} else {
None
}
} else {
None
}
}
fn is_write_patch_constrained_to_writable_paths(
action: &ApplyPatchAction,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::exec::SandboxType;
use crate::protocol::SandboxPolicy;
use crate::safety::get_platform_sandbox;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_sandboxing::SandboxType;
use codex_sandboxing::get_platform_sandbox;
pub(crate) fn sandbox_tag(
policy: &SandboxPolicy,
+2 -2
View File
@@ -1,9 +1,9 @@
use super::sandbox_tag;
use crate::exec::SandboxType;
use crate::protocol::SandboxPolicy;
use crate::safety::get_platform_sandbox;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::NetworkAccess;
use codex_sandboxing::SandboxType;
use codex_sandboxing::get_platform_sandbox;
use pretty_assertions::assert_eq;
#[test]
@@ -1,154 +0,0 @@
use std::collections::BTreeSet;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsContactsPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
/// Merges macOS seatbelt profile extensions by taking the permissive union of
/// each permission field.
pub(crate) fn merge_macos_seatbelt_profile_extensions(
base: Option<&MacOsSeatbeltProfileExtensions>,
permissions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Option<MacOsSeatbeltProfileExtensions> {
let Some(permissions) = permissions else {
return base.cloned();
};
match base {
Some(base) => Some(MacOsSeatbeltProfileExtensions {
macos_preferences: union_macos_preferences_permission(
&base.macos_preferences,
&permissions.macos_preferences,
),
macos_automation: union_macos_automation_permission(
&base.macos_automation,
&permissions.macos_automation,
),
macos_launch_services: base.macos_launch_services || permissions.macos_launch_services,
macos_accessibility: base.macos_accessibility || permissions.macos_accessibility,
macos_calendar: base.macos_calendar || permissions.macos_calendar,
macos_reminders: base.macos_reminders || permissions.macos_reminders,
macos_contacts: union_macos_contacts_permission(
&base.macos_contacts,
&permissions.macos_contacts,
),
}),
None => Some(permissions.clone()),
}
}
pub(crate) fn intersect_macos_seatbelt_profile_extensions(
requested: Option<MacOsSeatbeltProfileExtensions>,
granted: Option<MacOsSeatbeltProfileExtensions>,
) -> Option<MacOsSeatbeltProfileExtensions> {
match (requested, granted) {
(Some(requested), Some(granted)) => {
let macos_automation = intersect_macos_automation_permission(
&requested.macos_automation,
&granted.macos_automation,
);
Some(MacOsSeatbeltProfileExtensions {
macos_preferences: requested.macos_preferences.min(granted.macos_preferences),
macos_automation,
macos_launch_services: requested.macos_launch_services
&& granted.macos_launch_services,
macos_accessibility: requested.macos_accessibility && granted.macos_accessibility,
macos_calendar: requested.macos_calendar && granted.macos_calendar,
macos_reminders: requested.macos_reminders && granted.macos_reminders,
macos_contacts: requested.macos_contacts.min(granted.macos_contacts),
})
}
_ => None,
}
}
/// Unions two preferences permissions by keeping the more permissive one.
///
/// The larger rank wins: `None < ReadOnly < ReadWrite`. When both sides have
/// the same rank, this keeps `base`.
fn union_macos_preferences_permission(
base: &MacOsPreferencesPermission,
requested: &MacOsPreferencesPermission,
) -> MacOsPreferencesPermission {
if base < requested {
requested.clone()
} else {
base.clone()
}
}
fn union_macos_contacts_permission(
base: &MacOsContactsPermission,
requested: &MacOsContactsPermission,
) -> MacOsContactsPermission {
if base < requested {
requested.clone()
} else {
base.clone()
}
}
/// Unions two automation permissions by keeping the more permissive result.
///
/// `All` wins over everything, `None` yields to the other side, and two bundle
/// ID allowlists are unioned together.
fn union_macos_automation_permission(
base: &MacOsAutomationPermission,
requested: &MacOsAutomationPermission,
) -> MacOsAutomationPermission {
match (base, requested) {
(MacOsAutomationPermission::All, _) | (_, MacOsAutomationPermission::All) => {
MacOsAutomationPermission::All
}
(MacOsAutomationPermission::None, _) => requested.clone(),
(_, MacOsAutomationPermission::None) => base.clone(),
(
MacOsAutomationPermission::BundleIds(base_bundle_ids),
MacOsAutomationPermission::BundleIds(requested_bundle_ids),
) => MacOsAutomationPermission::BundleIds(
base_bundle_ids
.iter()
.chain(requested_bundle_ids.iter())
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
),
}
}
fn intersect_macos_automation_permission(
requested: &MacOsAutomationPermission,
granted: &MacOsAutomationPermission,
) -> MacOsAutomationPermission {
match (requested, granted) {
(_, MacOsAutomationPermission::None) | (MacOsAutomationPermission::None, _) => {
MacOsAutomationPermission::None
}
(MacOsAutomationPermission::All, granted) => granted.clone(),
(MacOsAutomationPermission::BundleIds(requested), MacOsAutomationPermission::All) => {
MacOsAutomationPermission::BundleIds(requested.clone())
}
(
MacOsAutomationPermission::BundleIds(requested),
MacOsAutomationPermission::BundleIds(granted),
) => {
let bundle_ids = requested
.iter()
.filter(|bundle_id| granted.contains(bundle_id))
.cloned()
.collect::<Vec<String>>();
if bundle_ids.is_empty() {
MacOsAutomationPermission::None
} else {
MacOsAutomationPermission::BundleIds(bundle_ids)
}
}
}
}
#[cfg(all(test, target_os = "macos"))]
#[path = "macos_permissions_tests.rs"]
mod tests;
@@ -1,121 +0,0 @@
use super::intersect_macos_automation_permission;
use super::intersect_macos_seatbelt_profile_extensions;
use super::merge_macos_seatbelt_profile_extensions;
use super::union_macos_automation_permission;
use super::union_macos_contacts_permission;
use super::union_macos_preferences_permission;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsContactsPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use pretty_assertions::assert_eq;
#[test]
fn merge_extensions_widens_permissions() {
let base = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadOnly,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
]),
macos_launch_services: false,
macos_accessibility: false,
macos_calendar: false,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::ReadOnly,
};
let requested = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
"com.apple.Calendar".to_string(),
]),
macos_launch_services: true,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: true,
macos_contacts: MacOsContactsPermission::ReadWrite,
};
let merged =
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&requested)).expect("merge");
assert_eq!(
merged,
MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
"com.apple.Notes".to_string(),
]),
macos_launch_services: true,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: true,
macos_contacts: MacOsContactsPermission::ReadWrite,
}
);
}
#[test]
fn union_macos_preferences_permission_does_not_downgrade() {
let base = MacOsPreferencesPermission::ReadWrite;
let requested = MacOsPreferencesPermission::ReadOnly;
let merged = union_macos_preferences_permission(&base, &requested);
assert_eq!(merged, MacOsPreferencesPermission::ReadWrite);
}
#[test]
fn union_macos_automation_permission_all_is_dominant() {
let base = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]);
let requested = MacOsAutomationPermission::All;
let merged = union_macos_automation_permission(&base, &requested);
assert_eq!(merged, MacOsAutomationPermission::All);
}
#[test]
fn intersect_macos_automation_permission_keeps_common_bundle_ids() {
let requested = MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
"com.apple.Calendar".to_string(),
]);
let granted = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]);
let intersected = intersect_macos_automation_permission(&requested, &granted);
assert_eq!(
intersected,
MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()])
);
}
#[test]
fn intersect_macos_seatbelt_profile_extensions_preserves_default_grant() {
let requested = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]),
macos_launch_services: false,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
};
let granted = MacOsSeatbeltProfileExtensions::default();
let intersected = intersect_macos_seatbelt_profile_extensions(Some(requested), Some(granted));
assert_eq!(intersected, Some(MacOsSeatbeltProfileExtensions::default()));
}
#[test]
fn union_macos_contacts_permission_does_not_downgrade() {
let base = MacOsContactsPermission::ReadWrite;
let requested = MacOsContactsPermission::ReadOnly;
let merged = union_macos_contacts_permission(&base, &requested);
assert_eq!(merged, MacOsContactsPermission::ReadWrite);
}
+48 -673
View File
@@ -1,65 +1,37 @@
/*
Module: sandboxing
Build platform wrappers and produce ExecRequest for execution. Owns low-level
sandbox placement and transformation of portable CommandSpec into a
ready‑to‑spawn environment.
Core-owned adapter types for exec/runtime plumbing. Policy selection and
command transformation live in the codex-sandboxing crate; this module keeps
the exec-only metadata and translates transformed sandbox commands back into
ExecRequest for execution.
*/
pub(crate) mod macos_permissions;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::execute_exec_request;
use crate::landlock::allow_network_for_proxy;
use crate::landlock::create_linux_sandbox_command_args_for_policies;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
#[cfg(target_os = "macos")]
use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
#[cfg(target_os = "macos")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::SandboxPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
use macos_permissions::intersect_macos_seatbelt_profile_extensions;
use macos_permissions::merge_macos_seatbelt_profile_extensions;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxExecRequest;
use codex_sandboxing::SandboxType;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug)]
pub struct CommandSpec {
pub program: String,
pub args: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub expiration: ExecExpiration,
pub capture_policy: ExecCapturePolicy,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
pub(crate) struct ExecOptions {
pub(crate) expiration: ExecExpiration,
pub(crate) capture_policy: ExecCapturePolicy,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) justification: Option<String>,
}
#[derive(Debug)]
@@ -81,650 +53,57 @@ pub struct ExecRequest {
pub arg0: Option<String>,
}
/// Bundled arguments for sandbox transformation.
///
/// This keeps call sites self-documenting when several fields are optional.
pub(crate) struct SandboxTransformRequest<'a> {
pub spec: CommandSpec,
pub policy: &'a SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub sandbox: SandboxType,
pub enforce_managed_network: bool,
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
// to make shared ownership explicit across runtime/sandbox plumbing.
pub network: Option<&'a NetworkProxy>,
pub sandbox_policy_cwd: &'a Path,
#[cfg(target_os = "macos")]
pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
pub use_legacy_landlock: bool,
pub windows_sandbox_level: WindowsSandboxLevel,
pub windows_sandbox_private_desktop: bool,
}
pub enum SandboxPreference {
Auto,
Require,
Forbid,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum SandboxTransformError {
#[error("missing codex-linux-sandbox executable path")]
MissingLinuxSandboxExecutable,
#[cfg(not(target_os = "macos"))]
#[error("seatbelt sandbox is only available on macOS")]
SeatbeltUnavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EffectiveSandboxPermissions {
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
}
impl EffectiveSandboxPermissions {
pub(crate) fn new(
sandbox_policy: &SandboxPolicy,
macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>,
additional_permissions: Option<&PermissionProfile>,
impl ExecRequest {
pub(crate) fn from_sandbox_exec_request(
request: SandboxExecRequest,
options: ExecOptions,
) -> Self {
let Some(additional_permissions) = additional_permissions else {
return Self {
sandbox_policy: sandbox_policy.clone(),
macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(),
};
};
Self {
sandbox_policy: sandbox_policy_with_additional_permissions(
sandbox_policy,
additional_permissions,
),
macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions(
macos_seatbelt_profile_extensions,
additional_permissions.macos.as_ref(),
),
}
}
}
pub(crate) fn normalize_additional_permissions(
additional_permissions: PermissionProfile,
) -> Result<PermissionProfile, String> {
let network = additional_permissions
.network
.filter(|network| !network.is_empty());
let file_system = additional_permissions
.file_system
.map(|file_system| {
let read = file_system
.read
.map(|paths| normalize_permission_paths(paths, "file_system.read"));
let write = file_system
.write
.map(|paths| normalize_permission_paths(paths, "file_system.write"));
FileSystemPermissions { read, write }
})
.filter(|file_system| !file_system.is_empty());
let macos = additional_permissions.macos;
Ok(PermissionProfile {
network,
file_system,
macos,
})
}
pub(crate) fn merge_permission_profiles(
base: Option<&PermissionProfile>,
permissions: Option<&PermissionProfile>,
) -> Option<PermissionProfile> {
let Some(permissions) = permissions else {
return base.cloned();
};
match base {
Some(base) => {
let network = match (base.network.as_ref(), permissions.network.as_ref()) {
(
Some(NetworkPermissions {
enabled: Some(true),
}),
_,
)
| (
_,
Some(NetworkPermissions {
enabled: Some(true),
}),
) => Some(NetworkPermissions {
enabled: Some(true),
}),
_ => None,
};
let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) {
(Some(base), Some(permissions)) => Some(FileSystemPermissions {
read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()),
write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()),
})
.filter(|file_system| !file_system.is_empty()),
(Some(base), None) => Some(base.clone()),
(None, Some(permissions)) => Some(permissions.clone()),
(None, None) => None,
};
let macos = merge_macos_seatbelt_profile_extensions(
base.macos.as_ref(),
permissions.macos.as_ref(),
);
Some(PermissionProfile {
network,
file_system,
macos,
})
.filter(|permissions| !permissions.is_empty())
}
None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()),
}
}
pub fn intersect_permission_profiles(
requested: PermissionProfile,
granted: PermissionProfile,
) -> PermissionProfile {
let file_system = requested
.file_system
.map(|requested_file_system| {
let granted_file_system = granted.file_system.unwrap_or_default();
let read = requested_file_system
.read
.map(|requested_read| {
let granted_read = granted_file_system.read.unwrap_or_default();
requested_read
.into_iter()
.filter(|path| granted_read.contains(path))
.collect()
})
.filter(|paths: &Vec<_>| !paths.is_empty());
let write = requested_file_system
.write
.map(|requested_write| {
let granted_write = granted_file_system.write.unwrap_or_default();
requested_write
.into_iter()
.filter(|path| granted_write.contains(path))
.collect()
})
.filter(|paths: &Vec<_>| !paths.is_empty());
FileSystemPermissions { read, write }
})
.filter(|file_system| !file_system.is_empty());
let network = match (requested.network, granted.network) {
(
Some(NetworkPermissions {
enabled: Some(true),
}),
Some(NetworkPermissions {
enabled: Some(true),
}),
) => Some(NetworkPermissions {
enabled: Some(true),
}),
_ => None,
};
let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos);
PermissionProfile {
network,
file_system,
macos,
}
}
fn normalize_permission_paths(
paths: Vec<AbsolutePathBuf>,
_permission_kind: &str,
) -> Vec<AbsolutePathBuf> {
let mut out = Vec::with_capacity(paths.len());
let mut seen = HashSet::new();
for path in paths {
let canonicalized = canonicalize(path.as_path())
.ok()
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
.unwrap_or(path);
if seen.insert(canonicalized.clone()) {
out.push(canonicalized);
}
}
out
}
fn merge_permission_paths(
base: Option<&Vec<AbsolutePathBuf>>,
permissions: Option<&Vec<AbsolutePathBuf>>,
) -> Option<Vec<AbsolutePathBuf>> {
match (base, permissions) {
(Some(base), Some(permissions)) => {
let mut merged = Vec::with_capacity(base.len() + permissions.len());
let mut seen = HashSet::with_capacity(base.len() + permissions.len());
for path in base.iter().chain(permissions.iter()) {
if seen.insert(path.clone()) {
merged.push(path.clone());
}
}
Some(merged).filter(|paths| !paths.is_empty())
}
(Some(base), None) => Some(base.clone()),
(None, Some(permissions)) => Some(permissions.clone()),
(None, None) => None,
}
}
fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
let mut out = Vec::with_capacity(paths.len());
let mut seen = HashSet::new();
for path in paths {
if seen.insert(path.to_path_buf()) {
out.push(path);
}
}
out
}
fn additional_permission_roots(
additional_permissions: &PermissionProfile,
) -> (Vec<AbsolutePathBuf>, Vec<AbsolutePathBuf>) {
(
dedup_absolute_paths(
additional_permissions
.file_system
.as_ref()
.and_then(|file_system| file_system.read.clone())
.unwrap_or_default(),
),
dedup_absolute_paths(
additional_permissions
.file_system
.as_ref()
.and_then(|file_system| file_system.write.clone())
.unwrap_or_default(),
),
)
}
fn merge_file_system_policy_with_additional_permissions(
file_system_policy: &FileSystemSandboxPolicy,
extra_reads: Vec<AbsolutePathBuf>,
extra_writes: Vec<AbsolutePathBuf>,
) -> FileSystemSandboxPolicy {
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => {
let mut merged_policy = file_system_policy.clone();
for path in extra_reads {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
for path in extra_writes {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
merged_policy
}
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
file_system_policy.clone()
}
}
}
pub(crate) fn effective_file_system_sandbox_policy(
file_system_policy: &FileSystemSandboxPolicy,
additional_permissions: Option<&PermissionProfile>,
) -> FileSystemSandboxPolicy {
let Some(additional_permissions) = additional_permissions else {
return file_system_policy.clone();
};
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
if extra_reads.is_empty() && extra_writes.is_empty() {
file_system_policy.clone()
} else {
merge_file_system_policy_with_additional_permissions(
file_system_policy,
extra_reads,
extra_writes,
)
}
}
fn merge_read_only_access_with_additional_reads(
read_only_access: &ReadOnlyAccess,
extra_reads: Vec<AbsolutePathBuf>,
) -> ReadOnlyAccess {
match read_only_access {
ReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess,
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
let mut merged = readable_roots.clone();
merged.extend(extra_reads);
ReadOnlyAccess::Restricted {
include_platform_defaults: *include_platform_defaults,
readable_roots: dedup_absolute_paths(merged),
}
}
}
}
fn merge_network_access(
base_network_access: bool,
additional_permissions: &PermissionProfile,
) -> bool {
base_network_access
|| additional_permissions
.network
.as_ref()
.and_then(|network| network.enabled)
.unwrap_or(false)
}
fn sandbox_policy_with_additional_permissions(
sandbox_policy: &SandboxPolicy,
additional_permissions: &PermissionProfile,
) -> SandboxPolicy {
if additional_permissions.is_empty() {
return sandbox_policy.clone();
}
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
match sandbox_policy {
SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess,
SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox {
network_access: if merge_network_access(
network_access.is_enabled(),
additional_permissions,
) {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
},
},
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => {
let mut merged_writes = writable_roots.clone();
merged_writes.extend(extra_writes);
SandboxPolicy::WorkspaceWrite {
writable_roots: dedup_absolute_paths(merged_writes),
read_only_access: merge_read_only_access_with_additional_reads(
read_only_access,
extra_reads,
),
network_access: merge_network_access(*network_access, additional_permissions),
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
}
}
SandboxPolicy::ReadOnly {
access,
network_access,
} => {
if extra_writes.is_empty() {
SandboxPolicy::ReadOnly {
access: merge_read_only_access_with_additional_reads(access, extra_reads),
network_access: merge_network_access(*network_access, additional_permissions),
}
} else {
// todo(dylan) - for now, this grants more access than the request. We should restrict this,
// but we should add a new SandboxPolicy variant to handle this. While the feature is still
// UnderDevelopment, it's a useful approximation of the desired behavior.
SandboxPolicy::WorkspaceWrite {
writable_roots: dedup_absolute_paths(extra_writes),
read_only_access: merge_read_only_access_with_additional_reads(
access,
extra_reads,
),
network_access: merge_network_access(*network_access, additional_permissions),
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
}
}
}
}
pub(crate) fn should_require_platform_sandbox(
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
has_managed_network_requirements: bool,
) -> bool {
if has_managed_network_requirements {
return true;
}
if !network_policy.is_enabled() {
return !matches!(
file_system_policy.kind,
FileSystemSandboxKind::ExternalSandbox
);
}
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => !file_system_policy.has_full_disk_write_access(),
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false,
}
}
#[derive(Default)]
pub struct SandboxManager;
impl SandboxManager {
pub fn new() -> Self {
Self
}
pub(crate) fn select_initial(
&self,
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
pref: SandboxablePreference,
windows_sandbox_level: WindowsSandboxLevel,
has_managed_network_requirements: bool,
) -> SandboxType {
match pref {
SandboxablePreference::Forbid => SandboxType::None,
SandboxablePreference::Require => {
// Require a platform sandbox when available; on Windows this
// respects the experimental_windows_sandbox feature.
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
}
SandboxablePreference::Auto => {
if should_require_platform_sandbox(
file_system_policy,
network_policy,
has_managed_network_requirements,
) {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
}
}
}
pub(crate) fn transform(
&self,
request: SandboxTransformRequest<'_>,
) -> Result<ExecRequest, SandboxTransformError> {
let SandboxTransformRequest {
mut spec,
policy,
file_system_policy,
network_policy,
sandbox,
enforce_managed_network,
let SandboxExecRequest {
command,
cwd,
mut env,
network,
sandbox_policy_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
codex_linux_sandbox_exe,
use_legacy_landlock,
sandbox,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
arg0,
} = request;
#[cfg(not(target_os = "macos"))]
let macos_seatbelt_profile_extensions = None;
let additional_permissions = spec.additional_permissions.take();
let EffectiveSandboxPermissions {
sandbox_policy: effective_policy,
macos_seatbelt_profile_extensions: _effective_macos_seatbelt_profile_extensions,
} = EffectiveSandboxPermissions::new(
policy,
macos_seatbelt_profile_extensions,
additional_permissions.as_ref(),
);
let (effective_file_system_policy, effective_network_policy) =
if let Some(additional_permissions) = additional_permissions {
let (extra_reads, extra_writes) =
additional_permission_roots(&additional_permissions);
let file_system_sandbox_policy =
if extra_reads.is_empty() && extra_writes.is_empty() {
file_system_policy.clone()
} else {
merge_file_system_policy_with_additional_permissions(
file_system_policy,
extra_reads,
extra_writes,
)
};
let network_sandbox_policy =
if merge_network_access(network_policy.is_enabled(), &additional_permissions) {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
};
(file_system_sandbox_policy, network_sandbox_policy)
} else {
(file_system_policy.clone(), network_policy)
};
let mut env = spec.env;
if !effective_network_policy.is_enabled() {
let ExecOptions {
expiration,
capture_policy,
sandbox_permissions,
justification,
} = options;
if !network_sandbox_policy.is_enabled() {
env.insert(
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
"1".to_string(),
);
}
let mut command = Vec::with_capacity(1 + spec.args.len());
command.push(spec.program);
command.append(&mut spec.args);
let (command, sandbox_env, arg0_override) = match sandbox {
SandboxType::None => (command, HashMap::new(), None),
#[cfg(target_os = "macos")]
SandboxType::MacosSeatbelt => {
let mut seatbelt_env = HashMap::new();
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let mut args = create_seatbelt_command_args_for_policies_with_extensions(
command.clone(),
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
_effective_macos_seatbelt_profile_extensions.as_ref(),
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
full_command.append(&mut args);
(full_command, seatbelt_env, None)
}
#[cfg(not(target_os = "macos"))]
SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable),
SandboxType::LinuxSeccomp => {
let exe = codex_linux_sandbox_exe
.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let mut args = create_linux_sandbox_command_args_for_policies(
command.clone(),
spec.cwd.as_path(),
&effective_policy,
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
use_legacy_landlock,
allow_proxy_network,
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(exe.to_string_lossy().to_string());
full_command.append(&mut args);
(
full_command,
HashMap::new(),
Some("codex-linux-sandbox".to_string()),
)
}
// On Windows, the restricted token sandbox executes in-process via the
// codex-windows-sandbox crate. We leave the command unchanged here and
// branch during execution based on the sandbox type.
#[cfg(target_os = "windows")]
SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None),
// When building for non-Windows targets, this variant is never constructed.
#[cfg(not(target_os = "windows"))]
SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None),
};
env.extend(sandbox_env);
Ok(ExecRequest {
#[cfg(target_os = "macos")]
if sandbox == SandboxType::MacosSeatbelt {
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
}
Self {
command,
cwd: spec.cwd,
cwd,
env,
network: network.cloned(),
expiration: spec.expiration,
capture_policy: spec.capture_policy,
network,
expiration,
capture_policy,
sandbox,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_permissions: spec.sandbox_permissions,
sandbox_policy: effective_policy,
file_system_sandbox_policy: effective_file_system_policy,
network_sandbox_policy: effective_network_policy,
justification: spec.justification,
arg0: arg0_override,
})
}
pub fn denied(&self, sandbox: SandboxType, out: &ExecToolCallOutput) -> bool {
crate::exec::is_likely_sandbox_denied(sandbox, out)
sandbox_permissions,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
justification,
arg0,
}
}
}
@@ -750,7 +129,3 @@ pub async fn execute_exec_request_with_after_spawn(
let effective_policy = exec_request.sandbox_policy.clone();
execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
-771
View File
@@ -1,771 +0,0 @@
#[cfg(target_os = "macos")]
use super::EffectiveSandboxPermissions;
use super::SandboxManager;
use super::effective_file_system_sandbox_policy;
#[cfg(target_os = "macos")]
use super::intersect_permission_profiles;
use super::merge_file_system_policy_with_additional_permissions;
use super::normalize_additional_permissions;
use super::sandbox_policy_with_additional_permissions;
use super::should_require_platform_sandbox;
use crate::exec::SandboxType;
use crate::protocol::NetworkAccess;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::tools::sandboxing::SandboxablePreference;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::FileSystemPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsAutomationPermission;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsContactsPermission;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsPreferencesPermission;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
#[cfg(unix)]
use std::path::Path;
use tempfile::TempDir;
#[cfg(unix)]
fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(original, link)
}
#[test]
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
let manager = SandboxManager::new();
let sandbox = manager.select_initial(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
);
assert_eq!(sandbox, SandboxType::None);
}
#[test]
fn danger_full_access_uses_platform_sandbox_with_network_requirements() {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
true,
);
assert_eq!(sandbox, expected);
}
#[test]
fn restricted_file_system_uses_platform_sandbox_without_managed_network() {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}]),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
);
assert_eq!(sandbox, expected);
}
#[test]
fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
false
);
}
#[test]
fn root_write_policy_with_carveouts_still_uses_platform_sandbox() {
let blocked = AbsolutePathBuf::resolve_path_against_base(
"blocked",
std::env::current_dir().expect("current dir"),
)
.expect("blocked path");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: blocked },
access: FileSystemAccessMode::None,
},
]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
true
);
}
#[test]
fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false),
true
);
}
#[test]
fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
sandbox_permissions: super::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: crate::protocol::NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
.expect("transform");
assert_eq!(
exec_request.file_system_sandbox_policy,
FileSystemSandboxPolicy::unrestricted()
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn normalize_additional_permissions_preserves_network() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let permissions = normalize_additional_permissions(PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path.clone()]),
write: Some(vec![path.clone()]),
}),
..Default::default()
})
.expect("permissions");
assert_eq!(
permissions.network,
Some(NetworkPermissions {
enabled: Some(true),
})
);
assert_eq!(
permissions.file_system,
Some(FileSystemPermissions {
read: Some(vec![path.clone()]),
write: Some(vec![path]),
})
);
}
#[cfg(unix)]
#[test]
fn normalize_additional_permissions_canonicalizes_symlinked_write_paths() {
let temp_dir = TempDir::new().expect("create temp dir");
let real_root = temp_dir.path().join("real");
let link_root = temp_dir.path().join("link");
let write_dir = real_root.join("write");
std::fs::create_dir_all(&write_dir).expect("create write dir");
symlink_dir(&real_root, &link_root).expect("create symlinked root");
let link_write_dir =
AbsolutePathBuf::from_absolute_path(link_root.join("write")).expect("link write dir");
let expected_write_dir = AbsolutePathBuf::from_absolute_path(
write_dir.canonicalize().expect("canonicalize write dir"),
)
.expect("absolute canonical write dir");
let permissions = normalize_additional_permissions(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![link_write_dir]),
}),
..Default::default()
})
.expect("permissions");
assert_eq!(
permissions.file_system,
Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![expected_write_dir]),
})
);
}
#[test]
fn normalize_additional_permissions_drops_empty_nested_profiles() {
let permissions = normalize_additional_permissions(PermissionProfile {
network: Some(NetworkPermissions { enabled: None }),
file_system: Some(FileSystemPermissions {
read: None,
write: None,
}),
macos: None,
})
.expect("permissions");
assert_eq!(permissions, PermissionProfile::default());
}
#[cfg(target_os = "macos")]
#[test]
fn normalize_additional_permissions_preserves_default_macos_preferences_permission() {
let permissions = normalize_additional_permissions(PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions::default()),
..Default::default()
})
.expect("permissions");
assert_eq!(
permissions,
PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions::default()),
..Default::default()
}
);
}
#[cfg(target_os = "macos")]
#[test]
fn intersect_permission_profiles_preserves_default_macos_grants() {
let requested = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(Vec::from(["/tmp/requested"
.try_into()
.expect("absolute path")])),
write: None,
}),
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_launch_services: false,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
}),
..Default::default()
};
let granted = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(Vec::new()),
write: None,
}),
macos: Some(MacOsSeatbeltProfileExtensions::default()),
..Default::default()
};
assert_eq!(
intersect_permission_profiles(requested, granted),
PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions::default()),
..Default::default()
}
);
}
#[cfg(target_os = "macos")]
#[test]
fn normalize_additional_permissions_preserves_macos_permissions() {
let permissions = normalize_additional_permissions(PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_launch_services: true,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
}),
..Default::default()
})
.expect("permissions");
assert_eq!(
permissions.macos,
Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_launch_services: true,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
})
);
}
#[test]
fn read_only_additional_permissions_can_enable_network_without_writes() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let policy = sandbox_policy_with_additional_permissions(
&SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![path.clone()],
},
network_access: false,
},
&PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path.clone()]),
write: Some(Vec::new()),
}),
..Default::default()
},
);
assert_eq!(
policy,
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![path],
},
network_access: true,
}
);
}
#[cfg(target_os = "macos")]
#[test]
fn effective_permissions_merge_macos_extensions_with_additional_permissions() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let effective_permissions = EffectiveSandboxPermissions::new(
&SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![path.clone()],
},
network_access: false,
},
Some(&MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadOnly,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
]),
macos_launch_services: false,
macos_accessibility: false,
macos_calendar: false,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
}),
Some(&PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_launch_services: true,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
}),
..Default::default()
}),
);
assert_eq!(
effective_permissions.macos_seatbelt_profile_extensions,
Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
"com.apple.Notes".to_string(),
]),
macos_launch_services: true,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
})
);
}
#[test]
fn external_sandbox_additional_permissions_can_enable_network() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let policy = sandbox_policy_with_additional_permissions(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
&PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
},
);
assert_eq!(
policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
}
#[test]
fn transform_additional_permissions_enable_network_for_external_sandbox() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
}),
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
.expect("transform");
assert_eq!(
exec_request.sandbox_policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Enabled
);
}
#[test]
fn transform_additional_permissions_preserves_denied_entries() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let temp_dir = TempDir::new().expect("create temp dir");
let workspace_root = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = workspace_root.join("allowed").expect("allowed path");
let denied_path = workspace_root.join("denied").expect("denied path");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![allowed_path.clone()]),
}),
..Default::default()
}),
justification: None,
},
policy: &SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
},
file_system_policy: &FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
.expect("transform");
assert_eq!(
exec_request.file_system_sandbox_policy,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Write,
},
])
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() {
let temp_dir = TempDir::new().expect("create temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = cwd.join("allowed").expect("allowed path");
let denied_path = cwd.join("denied").expect("denied path");
let merged_policy = merge_file_system_policy_with_additional_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
vec![allowed_path.clone()],
Vec::new(),
);
assert_eq!(
merged_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
}),
true
);
assert_eq!(
merged_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Read,
}),
true
);
}
#[test]
fn effective_file_system_sandbox_policy_returns_base_policy_without_additional_permissions() {
let temp_dir = TempDir::new().expect("create temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let denied_path = cwd.join("denied").expect("denied path");
let base_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
},
]);
let effective_policy = effective_file_system_sandbox_policy(&base_policy, None);
assert_eq!(effective_policy, base_policy);
}
#[test]
fn effective_file_system_sandbox_policy_merges_additional_write_roots() {
let temp_dir = TempDir::new().expect("create temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = cwd.join("allowed").expect("allowed path");
let denied_path = cwd.join("denied").expect("denied path");
let base_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]);
let additional_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![allowed_path.clone()]),
}),
..Default::default()
};
let effective_policy =
effective_file_system_sandbox_policy(&base_policy, Some(&additional_permissions));
assert_eq!(
effective_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
}),
true
);
assert_eq!(
effective_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Write,
}),
true
);
}
+11 -556
View File
@@ -1,40 +1,19 @@
#![cfg(target_os = "macos")]
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::PROXY_URL_ENV_KEYS;
use codex_network_proxy::has_proxy_url_env_vars;
use codex_network_proxy::proxy_url_env_value;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::ffi::CStr;
use std::path::Path;
use std::path::PathBuf;
use tokio::process::Child;
use tracing::warn;
use url::Url;
use crate::protocol::SandboxPolicy;
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
use crate::seatbelt_permissions::build_seatbelt_extensions;
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str =
include_str!("restricted_read_only_platform_defaults.sbpl");
/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
/// to defend against an attacker trying to inject a malicious version on the
/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker
/// already has root access.
pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use tokio::process::Child;
pub async fn spawn_command_under_seatbelt(
command: Vec<String>,
@@ -45,12 +24,14 @@ pub async fn spawn_command_under_seatbelt(
network: Option<&NetworkProxy>,
mut env: HashMap<String, String>,
) -> std::io::Result<Child> {
let args = create_seatbelt_command_args(
let args = create_seatbelt_command_args_for_policies_with_extensions(
command,
sandbox_policy,
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
NetworkSandboxPolicy::from(sandbox_policy),
sandbox_policy_cwd,
/*enforce_managed_network*/ false,
network,
/*extensions*/ None,
);
let arg0 = None;
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
@@ -66,529 +47,3 @@ pub async fn spawn_command_under_seatbelt(
})
.await
}
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
fn proxy_scheme_default_port(scheme: &str) -> u16 {
match scheme {
"https" => 443,
"socks5" | "socks5h" | "socks4" | "socks4a" => 1080,
_ => 80,
}
}
fn proxy_loopback_ports_from_env(env: &HashMap<String, String>) -> Vec<u16> {
let mut ports = BTreeSet::new();
for key in PROXY_URL_ENV_KEYS {
let Some(proxy_url) = proxy_url_env_value(env, key) else {
continue;
};
let trimmed = proxy_url.trim();
if trimmed.is_empty() {
continue;
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
let Ok(parsed) = Url::parse(&candidate) else {
continue;
};
let Some(host) = parsed.host_str() else {
continue;
};
if !is_loopback_host(host) {
continue;
}
let scheme = parsed.scheme().to_ascii_lowercase();
let port = parsed
.port()
.unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str()));
ports.insert(port);
}
ports.into_iter().collect()
}
#[derive(Debug, Default)]
struct ProxyPolicyInputs {
ports: Vec<u16>,
has_proxy_config: bool,
allow_local_binding: bool,
unix_domain_socket_policy: UnixDomainSocketPolicy,
}
#[derive(Debug, Clone)]
// Keep allow-all and allowlist modes disjoint so we don't carry ignored state.
enum UnixDomainSocketPolicy {
AllowAll,
Restricted { allowed: Vec<AbsolutePathBuf> },
}
impl Default for UnixDomainSocketPolicy {
fn default() -> Self {
Self::Restricted { allowed: vec![] }
}
}
#[derive(Debug, Clone)]
struct UnixSocketPathParam {
index: usize,
path: AbsolutePathBuf,
}
fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs {
if let Some(network) = network {
let mut env = HashMap::new();
network.apply_to_env(&mut env);
let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() {
UnixDomainSocketPolicy::AllowAll
} else {
let allowed = network
.allow_unix_sockets()
.iter()
.filter_map(
|socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) {
Some(path) => Some((path.to_string_lossy().to_string(), path)),
None => {
warn!(
"ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}"
);
None
}
},
)
.collect::<BTreeMap<_, _>>()
.into_values()
.collect();
UnixDomainSocketPolicy::Restricted { allowed }
};
return ProxyPolicyInputs {
ports: proxy_loopback_ports_from_env(&env),
has_proxy_config: has_proxy_url_env_vars(&env),
allow_local_binding: network.allow_local_binding(),
unix_domain_socket_policy,
};
}
ProxyPolicyInputs::default()
}
fn normalize_path_for_sandbox(path: &Path) -> Option<AbsolutePathBuf> {
// `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current
// working directory, so keep the explicit check to avoid silently accepting relative entries.
if !path.is_absolute() {
return None;
}
let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?;
let normalized_path = absolute_path
.as_path()
.canonicalize()
.ok()
.and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok());
normalized_path.or(Some(absolute_path))
}
fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec<UnixSocketPathParam> {
let mut deduped_paths: BTreeMap<String, AbsolutePathBuf> = BTreeMap::new();
let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else {
return vec![];
};
for path in allowed {
deduped_paths
.entry(path.to_string_lossy().to_string())
.or_insert_with(|| path.clone());
}
deduped_paths
.into_values()
.enumerate()
.map(|(index, path)| UnixSocketPathParam { index, path })
.collect()
}
fn unix_socket_path_param_key(index: usize) -> String {
format!("UNIX_SOCKET_PATH_{index}")
}
fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> {
unix_socket_path_params(proxy)
.into_iter()
.map(|param| {
(
unix_socket_path_param_key(param.index),
param.path.into_path_buf(),
)
})
.collect()
}
/// Returns zero or more complete Seatbelt policy lines for unix socket rules.
/// When non-empty, the returned string is newline-terminated so callers can
/// append it directly to larger policy blocks.
fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String {
let socket_params = unix_socket_path_params(proxy);
let has_unix_socket_access = matches!(
proxy.unix_domain_socket_policy,
UnixDomainSocketPolicy::AllowAll
) || !socket_params.is_empty();
if !has_unix_socket_access {
return String::new();
}
let mut policy = String::new();
policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n");
if matches!(
proxy.unix_domain_socket_policy,
UnixDomainSocketPolicy::AllowAll
) {
// Keep AllowAll genuinely broad here; path qualifiers look narrower
// without a clear macOS behavioral benefit.
policy.push_str("(allow network-bind (local unix-socket))\n");
policy.push_str("(allow network-outbound (remote unix-socket))\n");
return policy;
}
for param in socket_params {
let key = unix_socket_path_param_key(param.index);
// Use subpath so allowlists cover sockets created beneath approved directories.
policy.push_str(&format!(
"(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n"
));
policy.push_str(&format!(
"(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n"
));
}
policy
}
#[cfg_attr(not(test), allow(dead_code))]
fn dynamic_network_policy(
sandbox_policy: &SandboxPolicy,
enforce_managed_network: bool,
proxy: &ProxyPolicyInputs,
) -> String {
dynamic_network_policy_for_network(
NetworkSandboxPolicy::from(sandbox_policy),
enforce_managed_network,
proxy,
)
}
fn dynamic_network_policy_for_network(
network_policy: NetworkSandboxPolicy,
enforce_managed_network: bool,
proxy: &ProxyPolicyInputs,
) -> String {
let should_use_restricted_network_policy =
!proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network;
if should_use_restricted_network_policy {
let mut policy = String::new();
if proxy.allow_local_binding {
policy.push_str("; allow loopback local binding and loopback traffic\n");
policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n");
policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n");
}
for port in &proxy.ports {
policy.push_str(&format!(
"(allow network-outbound (remote ip \"localhost:{port}\"))\n"
));
}
let unix_socket_policy = unix_socket_policy(proxy);
if !unix_socket_policy.is_empty() {
policy.push_str("; allow unix domain sockets for local IPC\n");
policy.push_str(&unix_socket_policy);
}
return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}");
}
if proxy.has_proxy_config {
// Proxy configuration is present but we could not infer any valid loopback endpoints.
// Fail closed to avoid silently widening network access in proxy-enforced sessions.
return String::new();
}
if enforce_managed_network {
// Managed network requirements are active but no usable proxy endpoints
// are available. Fail closed for network access.
return String::new();
}
if network_policy.is_enabled() {
// No proxy env is configured: retain the existing full-network behavior.
format!(
"(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}"
)
} else {
String::new()
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn create_seatbelt_command_args(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
) -> Vec<String> {
create_seatbelt_command_args_with_extensions(
command,
sandbox_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
/*extensions*/ None,
)
}
fn root_absolute_path() -> AbsolutePathBuf {
match AbsolutePathBuf::from_absolute_path(Path::new("/")) {
Ok(path) => path,
Err(err) => panic!("root path must be absolute: {err}"),
}
}
#[derive(Debug, Clone)]
struct SeatbeltAccessRoot {
root: AbsolutePathBuf,
excluded_subpaths: Vec<AbsolutePathBuf>,
}
fn build_seatbelt_access_policy(
action: &str,
param_prefix: &str,
roots: Vec<SeatbeltAccessRoot>,
) -> (String, Vec<(String, PathBuf)>) {
let mut policy_components = Vec::new();
let mut params = Vec::new();
for (index, access_root) in roots.into_iter().enumerate() {
let root =
normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root);
let root_param = format!("{param_prefix}_{index}");
params.push((root_param.clone(), root.into_path_buf()));
if access_root.excluded_subpaths.is_empty() {
policy_components.push(format!("(subpath (param \"{root_param}\"))"));
continue;
}
let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")];
for (excluded_index, excluded_subpath) in
access_root.excluded_subpaths.into_iter().enumerate()
{
let excluded_subpath =
normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath);
let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}");
params.push((excluded_param.clone(), excluded_subpath.into_path_buf()));
require_parts.push(format!(
"(require-not (subpath (param \"{excluded_param}\")))"
));
}
policy_components.push(format!("(require-all {} )", require_parts.join(" ")));
}
if policy_components.is_empty() {
(String::new(), Vec::new())
} else {
(
format!("(allow {action}\n{}\n)", policy_components.join(" ")),
params,
)
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn create_seatbelt_command_args_with_extensions(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
create_seatbelt_command_args_for_policies_with_extensions(
command,
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
NetworkSandboxPolicy::from(sandbox_policy),
sandbox_policy_cwd,
enforce_managed_network,
network,
extensions,
)
}
pub fn create_seatbelt_command_args_for_policies_with_extensions(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
let unreadable_roots =
file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd);
let (file_write_policy, file_write_dir_params) =
if file_system_sandbox_policy.has_full_disk_write_access() {
if unreadable_roots.is_empty() {
// Allegedly, this is more permissive than `(allow file-write*)`.
(
r#"(allow file-write* (regex #"^/"))"#.to_string(),
Vec::new(),
)
} else {
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
vec![SeatbeltAccessRoot {
root: root_absolute_path(),
excluded_subpaths: unreadable_roots.clone(),
}],
)
}
} else {
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
file_system_sandbox_policy
.get_writable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.map(|root| SeatbeltAccessRoot {
root: root.root,
excluded_subpaths: root.read_only_subpaths,
})
.collect(),
)
};
let (file_read_policy, file_read_dir_params) =
if file_system_sandbox_policy.has_full_disk_read_access() {
if unreadable_roots.is_empty() {
(
"; allow read-only file operations\n(allow file-read*)".to_string(),
Vec::new(),
)
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
vec![SeatbeltAccessRoot {
root: root_absolute_path(),
excluded_subpaths: unreadable_roots,
}],
);
(
format!("; allow read-only file operations\n{policy}"),
params,
)
}
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
file_system_sandbox_policy
.get_readable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.map(|root| SeatbeltAccessRoot {
excluded_subpaths: unreadable_roots
.iter()
.filter(|path| path.as_path().starts_with(root.as_path()))
.cloned()
.collect(),
root,
})
.collect(),
);
if policy.is_empty() {
(String::new(), params)
} else {
(
format!("; allow read-only file operations\n{policy}"),
params,
)
}
};
let proxy = proxy_policy_inputs(network);
let network_policy =
dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy);
let seatbelt_extensions = extensions.map_or_else(
|| {
// Backward-compatibility default when no extension profile is provided.
build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default())
},
build_seatbelt_extensions,
);
let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults();
let mut policy_sections = vec![
MACOS_SEATBELT_BASE_POLICY.to_string(),
file_read_policy,
file_write_policy,
network_policy,
];
if include_platform_defaults {
policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string());
}
if !seatbelt_extensions.policy.is_empty() {
policy_sections.push(seatbelt_extensions.policy.clone());
}
let full_policy = policy_sections.join("\n");
let dir_params = [
file_read_dir_params,
file_write_dir_params,
macos_dir_params(),
unix_socket_dir_params(&proxy),
seatbelt_extensions.dir_params,
]
.concat();
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
let definition_args = dir_params
.into_iter()
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy()));
seatbelt_args.extend(definition_args);
seatbelt_args.push("--".to_string());
seatbelt_args.extend(command);
seatbelt_args
}
/// Wraps libc::confstr to return a String.
fn confstr(name: libc::c_int) -> Option<String> {
let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1];
let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) };
if len == 0 {
return None;
}
// confstr guarantees NUL-termination when len > 0.
let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) };
cstr.to_str().ok().map(ToString::to_string)
}
/// Wraps confstr to return a canonicalized PathBuf.
fn confstr_path(name: libc::c_int) -> Option<PathBuf> {
let s = confstr(name)?;
let path = PathBuf::from(s);
path.canonicalize().ok().or(Some(path))
}
fn macos_dir_params() -> Vec<(String, PathBuf)> {
if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) {
return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)];
}
vec![]
}
#[cfg(test)]
#[path = "seatbelt_tests.rs"]
mod tests;
-108
View File
@@ -1,108 +0,0 @@
(version 1)
; inspired by Chrome's sandbox policy:
; https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
; https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/renderer.sb;l=64;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
; start with closed-by-default
(deny default)
; child processes inherit the policy of their parent
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
; process-info
(allow process-info* (target same-sandbox))
(allow file-write-data
(require-all
(path "/dev/null")
(vnode-type CHARACTER-DEVICE)))
; sysctls permitted.
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.model")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
; Chrome locks these CPU feature detection down a bit more tightly,
; but mostly for fingerprinting concerns which isn't an issue for codex.
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "kern.argmax")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "net.routetable.")
)
; Allow Java to read some CPU info. This is misclassified as a "write" because
; userspace passes a memory buffer to the sysctl, but conceptually it is a read.
(allow sysctl-write
(sysctl-name "kern.grade_cputype"))
; IOKit
(allow iokit-open
(iokit-registry-entry-class "RootDomainUserClient")
)
; needed to look up user info, see https://crbug.com/792228
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo")
)
; Needed for python multiprocessing on MacOS for the SemLock
(allow ipc-posix-sem)
(allow mach-lookup
(global-name "com.apple.PowerManagement.control")
)
; allow openpty()
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write*
(require-all
(regex #"^/dev/ttys[0-9]+")
(extension "com.apple.sandbox.pty")))
; PTYs created before entering seatbelt may lack the extension; allow ioctl
; on those slave ttys so interactive shells detect a TTY and remain functional.
(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))
@@ -1,35 +0,0 @@
; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl
; proxy-specific allow rules are injected by codex-core based on environment.
; Ref https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/network.sb;drc=f8f264d5e4e7509c913f4c60c2639d15905a07e4
; allow only safe AF_SYSTEM sockets used for local platform services.
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
(allow mach-lookup
; Used to look up the _CS_DARWIN_USER_CACHE_DIR in the sandbox.
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.system.opendirectoryd.membership")
; Communicate with the security server for TLS certificate information.
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd.agent")
; Read network configuration.
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
(allow sysctl-read
(sysctl-name-regex #"^net.routetable")
)
(allow file-write*
(subpath (param "DARWIN_USER_CACHE_DIR"))
)
-192
View File
@@ -1,192 +0,0 @@
#![cfg(target_os = "macos")]
use std::collections::BTreeSet;
use std::path::PathBuf;
pub use codex_protocol::models::MacOsAutomationPermission;
pub use codex_protocol::models::MacOsContactsPermission;
pub use codex_protocol::models::MacOsPreferencesPermission;
pub use codex_protocol::models::MacOsSeatbeltProfileExtensions;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct SeatbeltExtensionPolicy {
pub(crate) policy: String,
pub(crate) dir_params: Vec<(String, PathBuf)>,
}
fn normalized_extensions(
extensions: &MacOsSeatbeltProfileExtensions,
) -> MacOsSeatbeltProfileExtensions {
let mut normalized = extensions.clone();
if let MacOsAutomationPermission::BundleIds(bundle_ids) = &extensions.macos_automation {
let bundle_ids = normalize_bundle_ids(bundle_ids);
normalized.macos_automation = if bundle_ids.is_empty() {
MacOsAutomationPermission::None
} else {
MacOsAutomationPermission::BundleIds(bundle_ids)
};
}
normalized
}
pub(crate) fn build_seatbelt_extensions(
extensions: &MacOsSeatbeltProfileExtensions,
) -> SeatbeltExtensionPolicy {
let extensions = normalized_extensions(extensions);
let mut clauses = Vec::new();
match extensions.macos_preferences {
MacOsPreferencesPermission::None => {}
MacOsPreferencesPermission::ReadOnly => {
clauses.push(
"(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"
.to_string(),
);
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))"
.to_string(),
);
clauses.push("(allow user-preference-read)".to_string());
}
MacOsPreferencesPermission::ReadWrite => {
clauses.push(
"(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"
.to_string(),
);
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))"
.to_string(),
);
clauses.push("(allow user-preference-read)".to_string());
clauses.push("(allow user-preference-write)".to_string());
clauses.push(
"(allow ipc-posix-shm-write-data (ipc-posix-name-prefix \"apple.cfprefs.\"))"
.to_string(),
);
clauses.push(
"(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))"
.to_string(),
);
}
}
match extensions.macos_automation {
MacOsAutomationPermission::None => {}
MacOsAutomationPermission::All => {
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.coreservices.appleevents\"))"
.to_string(),
);
clauses.push("(allow appleevent-send)".to_string());
}
MacOsAutomationPermission::BundleIds(bundle_ids) => {
if !bundle_ids.is_empty() {
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.coreservices.appleevents\"))"
.to_string(),
);
let destinations = bundle_ids
.iter()
.map(|bundle_id| format!(" (appleevent-destination \"{bundle_id}\")"))
.collect::<Vec<String>>()
.join("\n");
clauses.push(format!("(allow appleevent-send\n{destinations}\n)"));
}
}
}
if extensions.macos_launch_services {
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.coreservices.launchservicesd\")\n (global-name \"com.apple.lsd.mapdb\")\n (global-name \"com.apple.coreservices.quarantine-resolver\")\n (global-name \"com.apple.lsd.modifydb\"))"
.to_string(),
);
clauses.push("(allow lsopen)".to_string());
}
if extensions.macos_accessibility {
clauses.push("(allow mach-lookup (local-name \"com.apple.axserver\"))".to_string());
}
if extensions.macos_calendar {
clauses.push("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))".to_string());
}
if extensions.macos_reminders {
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.CalendarAgent\")\n (global-name \"com.apple.remindd\"))"
.to_string(),
);
}
let mut dir_params = Vec::new();
match extensions.macos_contacts {
MacOsContactsPermission::None => {}
MacOsContactsPermission::ReadOnly => {
clauses.push(
"(allow file-read* file-test-existence\n (subpath \"/System/Library/Address Book Plug-Ins\")\n (subpath (param \"ADDRESSBOOK_DIR\")))"
.to_string(),
);
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.tccd\")\n (global-name \"com.apple.tccd.system\")\n (global-name \"com.apple.contactsd.persistence\")\n (global-name \"com.apple.AddressBook.ContactsAccountsService\")\n (global-name \"com.apple.contacts.account-caching\")\n (global-name \"com.apple.accountsd.accountmanager\"))"
.to_string(),
);
if let Some(addressbook_dir) = addressbook_dir() {
dir_params.push(("ADDRESSBOOK_DIR".to_string(), addressbook_dir));
}
}
MacOsContactsPermission::ReadWrite => {
clauses.push(
"(allow file-read* file-write*\n (subpath \"/System/Library/Address Book Plug-Ins\")\n (subpath (param \"ADDRESSBOOK_DIR\"))\n (subpath \"/var/folders\")\n (subpath \"/private/var/folders\"))"
.to_string(),
);
clauses.push(
"(allow mach-lookup\n (global-name \"com.apple.tccd\")\n (global-name \"com.apple.tccd.system\")\n (global-name \"com.apple.contactsd.persistence\")\n (global-name \"com.apple.AddressBook.ContactsAccountsService\")\n (global-name \"com.apple.contacts.account-caching\")\n (global-name \"com.apple.accountsd.accountmanager\")\n (global-name \"com.apple.securityd.xpc\"))"
.to_string(),
);
if let Some(addressbook_dir) = addressbook_dir() {
dir_params.push(("ADDRESSBOOK_DIR".to_string(), addressbook_dir));
}
}
}
if clauses.is_empty() {
SeatbeltExtensionPolicy::default()
} else {
SeatbeltExtensionPolicy {
policy: format!(
"; macOS permission profile extensions\n{}\n",
clauses.join("\n")
),
dir_params,
}
}
}
fn addressbook_dir() -> Option<PathBuf> {
Some(dirs::home_dir()?.join("Library/Application Support/AddressBook"))
}
fn normalize_bundle_ids(bundle_ids: &[String]) -> Vec<String> {
let mut unique = BTreeSet::new();
for bundle_id in bundle_ids {
let candidate = bundle_id.trim();
if is_valid_bundle_id(candidate) {
unique.insert(candidate.to_string());
}
}
unique.into_iter().collect()
}
fn is_valid_bundle_id(bundle_id: &str) -> bool {
if bundle_id.len() < 3 || !bundle_id.contains('.') {
return false;
}
bundle_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
}
#[cfg(test)]
#[path = "seatbelt_permissions_tests.rs"]
mod tests;
@@ -1,154 +0,0 @@
use super::MacOsAutomationPermission;
use super::MacOsContactsPermission;
use super::MacOsPreferencesPermission;
use super::MacOsSeatbeltProfileExtensions;
use super::build_seatbelt_extensions;
#[test]
fn preferences_read_only_emits_read_clauses_only() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadOnly,
..Default::default()
});
assert!(policy.policy.contains("(allow user-preference-read)"));
assert!(!policy.policy.contains("(allow user-preference-write)"));
}
#[test]
fn preferences_read_write_emits_write_clauses() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
..Default::default()
});
assert!(policy.policy.contains("(allow user-preference-read)"));
assert!(policy.policy.contains("(allow user-preference-write)"));
assert!(
policy.policy.contains(
"(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))"
)
);
}
#[test]
fn automation_all_emits_unscoped_appleevents() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_automation: MacOsAutomationPermission::All,
..Default::default()
});
assert!(policy.policy.contains("(allow appleevent-send)"));
assert!(policy.policy.contains("com.apple.coreservices.appleevents"));
}
#[test]
fn automation_bundle_ids_are_normalized_and_scoped() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_automation: MacOsAutomationPermission::BundleIds(vec![
" com.apple.Notes ".to_string(),
"com.apple.Calendar".to_string(),
"bad bundle".to_string(),
"com.apple.Notes".to_string(),
]),
..Default::default()
});
assert!(
policy
.policy
.contains("(appleevent-destination \"com.apple.Calendar\")")
);
assert!(
policy
.policy
.contains("(appleevent-destination \"com.apple.Notes\")")
);
assert!(!policy.policy.contains("bad bundle"));
assert!(policy.policy.contains("com.apple.coreservices.appleevents"));
}
#[test]
fn launch_services_emit_launch_clauses() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_launch_services: true,
..Default::default()
});
assert!(
policy
.policy
.contains("com.apple.coreservices.launchservicesd")
);
assert!(policy.policy.contains("com.apple.lsd.mapdb"));
assert!(
policy
.policy
.contains("com.apple.coreservices.quarantine-resolver")
);
assert!(policy.policy.contains("com.apple.lsd.modifydb"));
assert!(policy.policy.contains("(allow lsopen)"));
}
#[test]
fn accessibility_and_calendar_emit_mach_lookups() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_accessibility: true,
macos_calendar: true,
..Default::default()
});
assert!(policy.policy.contains("com.apple.axserver"));
assert!(policy.policy.contains("com.apple.CalendarAgent"));
}
#[test]
fn reminders_emit_calendar_agent_and_remindd_lookups() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_reminders: true,
..Default::default()
});
assert!(policy.policy.contains("com.apple.CalendarAgent"));
assert!(policy.policy.contains("com.apple.remindd"));
}
#[test]
fn contacts_read_only_emit_contacts_read_clauses() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_contacts: MacOsContactsPermission::ReadOnly,
..Default::default()
});
assert!(
policy
.policy
.contains("(subpath \"/System/Library/Address Book Plug-Ins\")")
);
assert!(
policy
.policy
.contains("(subpath (param \"ADDRESSBOOK_DIR\"))")
);
assert!(policy.policy.contains("com.apple.contactsd.persistence"));
assert!(policy.policy.contains("com.apple.accountsd.accountmanager"));
assert!(!policy.policy.contains("com.apple.securityd.xpc"));
assert!(
policy
.dir_params
.iter()
.any(|(key, _)| key == "ADDRESSBOOK_DIR")
);
}
#[test]
fn contacts_read_write_emit_write_clauses() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
macos_contacts: MacOsContactsPermission::ReadWrite,
..Default::default()
});
assert!(policy.policy.contains("(subpath \"/var/folders\")"));
assert!(policy.policy.contains("(subpath \"/private/var/folders\")"));
assert!(policy.policy.contains("com.apple.securityd.xpc"));
}
#[test]
fn default_extensions_emit_preferences_read_only_policy() {
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default());
assert!(policy.policy.contains("(allow user-preference-read)"));
assert!(!policy.policy.contains("(allow user-preference-write)"));
}
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
//! Skills-specific watcher built on top of the generic [`FileWatcher`].
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::runtime::Handle;
use tokio::sync::broadcast;
use tracing::warn;
use crate::config::Config;
use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherSubscriber;
use crate::file_watcher::Receiver;
use crate::file_watcher::ThrottledWatchReceiver;
use crate::file_watcher::WatchPath;
use crate::file_watcher::WatchRegistration;
use crate::skills::SkillsManager;
#[cfg(not(test))]
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
#[cfg(test)]
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillsWatcherEvent {
SkillsChanged { paths: Vec<PathBuf> },
}
pub(crate) struct SkillsWatcher {
subscriber: FileWatcherSubscriber,
tx: broadcast::Sender<SkillsWatcherEvent>,
}
impl SkillsWatcher {
pub(crate) fn new(file_watcher: &Arc<FileWatcher>) -> Self {
let (subscriber, rx) = file_watcher.add_subscriber();
let (tx, _) = broadcast::channel(128);
let skills_watcher = Self {
subscriber,
tx: tx.clone(),
};
Self::spawn_event_loop(rx, tx);
skills_watcher
}
pub(crate) fn noop() -> Self {
Self::new(&Arc::new(FileWatcher::noop()))
}
pub(crate) fn subscribe(&self) -> broadcast::Receiver<SkillsWatcherEvent> {
self.tx.subscribe()
}
pub(crate) fn register_config(
&self,
config: &Config,
skills_manager: &SkillsManager,
) -> WatchRegistration {
let roots = skills_manager
.skill_roots_for_config(config)
.into_iter()
.map(|root| WatchPath {
path: root.path,
recursive: true,
})
.collect();
self.subscriber.register_paths(roots)
}
fn spawn_event_loop(rx: Receiver, tx: broadcast::Sender<SkillsWatcherEvent>) {
let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL);
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
while let Some(event) = rx.recv().await {
let _ = tx.send(SkillsWatcherEvent::SkillsChanged { paths: event.paths });
}
});
} else {
warn!("skills watcher listener skipped: no Tokio runtime available");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tokio::time::Duration;
use tokio::time::timeout;
#[tokio::test]
async fn forwards_file_watcher_events() {
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = SkillsWatcher::new(&file_watcher);
let mut rx = skills_watcher.subscribe();
let _registration = skills_watcher
.subscriber
.register_path(PathBuf::from("/tmp/skill"), true);
file_watcher
.send_paths_for_test(vec![PathBuf::from("/tmp/skill/SKILL.md")])
.await;
let event = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("skills watcher event")
.expect("broadcast recv");
assert_eq!(
event,
SkillsWatcherEvent::SkillsChanged {
paths: vec![PathBuf::from("/tmp/skill/SKILL.md")],
}
);
}
}
+2 -2
View File
@@ -8,12 +8,12 @@ use crate::analytics_client::AnalyticsEventsClient;
use crate::client::ModelClient;
use crate::config::StartedNetworkProxy;
use crate::exec_policy::ExecPolicyManager;
use crate::file_watcher::FileWatcher;
use crate::mcp::McpManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::plugins::PluginsManager;
use crate::skills::SkillsManager;
use crate::skills_watcher::SkillsWatcher;
use crate::state_db::StateDbHandle;
use crate::tools::code_mode::CodeModeService;
use crate::tools::network_approval::NetworkApprovalService;
@@ -54,7 +54,7 @@ pub(crate) struct SessionServices {
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) file_watcher: Arc<FileWatcher>,
pub(crate) skills_watcher: Arc<SkillsWatcher>,
pub(crate) agent_control: AgentControl,
pub(crate) network_proxy: Option<StartedNetworkProxy>,
pub(crate) network_approval: Arc<NetworkApprovalService>,
+1 -1
View File
@@ -2,6 +2,7 @@
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -11,7 +12,6 @@ use crate::context_manager::ContextManager;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
use crate::sandboxing::merge_permission_profiles;
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
use crate::truncate::TruncationPolicy;
use codex_protocol::protocol::TurnContextItem;
+5 -1
View File
@@ -1,5 +1,6 @@
//! Turn-scoped state and active turn metadata scaffolding.
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::sync::Arc;
@@ -19,7 +20,6 @@ use tokio::sync::oneshot;
use crate::codex::TurnContext;
use crate::protocol::ReviewDecision;
use crate::protocol::TokenUsage;
use crate::sandboxing::merge_permission_profiles;
use crate::tasks::SessionTask;
use codex_protocol::models::PermissionProfile;
@@ -198,6 +198,10 @@ impl TurnState {
}
}
pub(crate) fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
self.pending_input.clone()
}
pub(crate) fn has_pending_input(&self) -> bool {
!self.pending_input.is_empty()
}
+4 -4
View File
@@ -5,10 +5,10 @@ use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use async_trait::async_trait;
use codex_git::CreateGhostCommitOptions;
use codex_git::GhostSnapshotReport;
use codex_git::GitToolingError;
use codex_git::create_ghost_commit_with_report;
use codex_git_utils::CreateGhostCommitOptions;
use codex_git_utils::GhostSnapshotReport;
use codex_git_utils::GitToolingError;
use codex_git_utils::create_ghost_commit_with_report;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use codex_utils_readiness::Readiness;
@@ -1,5 +1,5 @@
use super::*;
use codex_git::LargeUntrackedDir;
use codex_git_utils::LargeUntrackedDir;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
+35 -36
View File
@@ -22,6 +22,7 @@ use tracing::warn;
use crate::AuthManager;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::contextual_user_message::TURN_ABORTED_CLOSE_TAG;
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::inspect_pending_input;
@@ -29,7 +30,6 @@ use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::models_manager::manager::ModelsManager;
use crate::protocol::EventMsg;
use crate::protocol::TokenUsage;
use crate::protocol::TurnAbortReason;
use crate::protocol::TurnAbortedEvent;
use crate::protocol::TurnCompleteEvent;
@@ -60,6 +60,22 @@ pub(crate) use user_shell::execute_user_shell_command;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
/// Shared model-visible marker used by both the real interrupt path and
/// interrupted fork snapshots.
pub(crate) fn interrupted_turn_history_marker() -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n{TURN_ABORTED_CLOSE_TAG}"
),
}],
end_turn: None,
phase: None,
}
}
fn emit_turn_network_proxy_metric(
session_telemetry: &SessionTelemetry,
network_proxy_active: bool,
@@ -175,11 +191,6 @@ impl Session {
let cancellation_token = CancellationToken::new();
let done = Arc::new(Notify::new());
let timer = turn_context
.session_telemetry
.start_timer(TURN_E2E_DURATION_METRIC, &[])
.ok();
let done_clone = Arc::clone(&done);
let handle = {
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
@@ -219,6 +230,21 @@ impl Session {
)
};
let queued_response_items = self.take_queued_response_items_for_next_turn().await;
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
let mut turn_state = turn.turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
for item in queued_response_items {
turn_state.push_pending_input(item);
}
drop(turn_state);
let timer = turn_context
.session_telemetry
.start_timer(TURN_E2E_DURATION_METRIC, &[])
.ok();
let running_task = RunningTask {
done,
handle: Arc::new(AbortOnDropHandle::new(handle)),
@@ -228,8 +254,8 @@ impl Session {
turn_context: Arc::clone(&turn_context),
_timer: timer,
};
self.register_new_active_task(running_task, token_usage_at_turn_start)
.await;
turn.add_task(running_task);
*active = Some(turn);
}
pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc<Self>) {
@@ -389,23 +415,6 @@ impl Session {
self.send_event(turn_context.as_ref(), event).await;
}
async fn register_new_active_task(
&self,
task: RunningTask,
token_usage_at_turn_start: TokenUsage,
) {
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
let mut turn_state = turn.turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
for item in self.take_queued_response_items_for_next_turn().await {
turn_state.push_pending_input(item);
}
drop(turn_state);
turn.add_task(task);
*active = Some(turn);
}
async fn take_active_turn(&self) -> Option<ActiveTurn> {
let mut active = self.active_turn.lock().await;
active.take()
@@ -457,17 +466,7 @@ impl Session {
if reason == TurnAbortReason::Interrupted {
self.cleanup_after_interrupt(&task.turn_context).await;
let marker = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n</turn_aborted>"
),
}],
end_turn: None,
phase: None,
};
let marker = interrupted_turn_history_marker();
self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref())
.await;
self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)])
+17 -9
View File
@@ -63,14 +63,22 @@ impl SessionTask for RegularTask {
Some(*prewarmed_client_session)
}
};
run_turn(
sess,
ctx,
input,
prewarmed_client_session,
cancellation_token,
)
.instrument(run_turn_span)
.await
let mut next_input = input;
let mut prewarmed_client_session = prewarmed_client_session;
loop {
let last_agent_message = run_turn(
Arc::clone(&sess),
Arc::clone(&ctx),
next_input,
prewarmed_client_session.take(),
cancellation_token.child_token(),
)
.instrument(run_turn_span.clone())
.await;
if !sess.has_pending_input().await {
return last_agent_message;
}
next_input = Vec::new();
}
}
}
+2 -2
View File
@@ -8,8 +8,8 @@ use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use async_trait::async_trait;
use codex_git::RestoreGhostCommitOptions;
use codex_git::restore_ghost_commit_with_options;
use codex_git_utils::RestoreGhostCommitOptions;
use codex_git_utils::restore_ghost_commit_with_options;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
+1 -1
View File
@@ -12,7 +12,6 @@ use uuid::Uuid;
use crate::codex::TurnContext;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::StreamOutput;
use crate::exec::execute_exec_request;
@@ -31,6 +30,7 @@ use crate::state::TaskKind;
use crate::tools::format_exec_output_str;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::user_shell_command::user_shell_command_record_item;
use codex_sandboxing::SandboxType;
use super::SessionTask;
use super::SessionTaskContext;
+204 -32
View File
@@ -12,7 +12,6 @@ use crate::config::Config;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherEvent;
use crate::mcp::McpManager;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::manager::ModelsManager;
@@ -24,6 +23,11 @@ use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillsManager;
use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::tasks::interrupted_turn_history_marker;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationModeMask;
#[cfg(test)]
@@ -34,6 +38,8 @@ use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::W3cTraceContext;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
@@ -78,32 +84,33 @@ impl Drop for TempCodexHomeGuard {
}
}
fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc<SkillsManager>) -> Arc<FileWatcher> {
fn build_skills_watcher(skills_manager: Arc<SkillsManager>) -> Arc<SkillsWatcher> {
if should_use_test_thread_manager_behavior()
&& let Ok(handle) = Handle::try_current()
&& handle.runtime_flavor() == RuntimeFlavor::CurrentThread
{
// The real watcher spins background tasks that can starve the
// current-thread test runtime and cause event waits to time out.
warn!("using noop file watcher under current-thread test runtime");
return Arc::new(FileWatcher::noop());
warn!("using noop skills watcher under current-thread test runtime");
return Arc::new(SkillsWatcher::noop());
}
let file_watcher = match FileWatcher::new(codex_home) {
let file_watcher = match FileWatcher::new() {
Ok(file_watcher) => Arc::new(file_watcher),
Err(err) => {
warn!("failed to initialize file watcher: {err}");
Arc::new(FileWatcher::noop())
}
};
let skills_watcher = Arc::new(SkillsWatcher::new(&file_watcher));
let mut rx = file_watcher.subscribe();
let mut rx = skills_watcher.subscribe();
let skills_manager = Arc::clone(&skills_manager);
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
loop {
match rx.recv().await {
Ok(FileWatcherEvent::SkillsChanged { .. }) => {
Ok(SkillsWatcherEvent::SkillsChanged { .. }) => {
skills_manager.clear_cache();
}
Err(broadcast::error::RecvError::Closed) => break,
@@ -112,10 +119,10 @@ fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc<SkillsManager>) -
}
});
} else {
warn!("file watcher listener skipped: no Tokio runtime available");
warn!("skills watcher listener skipped: no Tokio runtime available");
}
file_watcher
skills_watcher
}
/// Represents a newly created Codex thread (formerly called a conversation), including the first event
@@ -126,6 +133,45 @@ pub struct NewThread {
pub session_configured: SessionConfiguredEvent,
}
// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once
// core can represent sampling boundaries directly instead of relying on
// whichever items happened to be persisted mid-turn.
//
// Two likely future variants:
// - `TruncateToLastSamplingBoundary` for callers that want a coherent fork from
// the last stable model boundary without synthesizing an interrupt.
// - `WaitUntilNextSamplingBoundary` (or similar) for callers that prefer to
// fork after the next sampling boundary rather than interrupting immediately.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForkSnapshot {
/// Fork a committed prefix ending strictly before the nth user message.
///
/// When `n` is within range, this cuts before that 0-based user-message
/// boundary. When `n` is out of range and the source thread is currently
/// mid-turn, this instead cuts before the active turn's opening boundary
/// so the fork drops the unfinished turn suffix. When `n` is out of range
/// and the source thread is already at a turn boundary, this returns the
/// full committed history unchanged.
TruncateBeforeNthUserMessage(usize),
/// Fork the current persisted history as if the source thread had been
/// interrupted now.
///
/// If the persisted snapshot ends mid-turn, this appends the same
/// `<turn_aborted>` marker produced by a real interrupt. If the snapshot is
/// already at a turn boundary, this returns the current persisted history
/// unchanged.
Interrupted,
}
/// Preserve legacy `fork_thread(usize, ...)` callsites by mapping them to the
/// existing truncate-before-nth-user-message snapshot mode.
impl From<usize> for ForkSnapshot {
fn from(value: usize) -> Self {
Self::TruncateBeforeNthUserMessage(value)
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ThreadShutdownReport {
pub completed: Vec<ThreadId>,
@@ -157,7 +203,7 @@ pub(crate) struct ThreadManagerState {
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
skills_watcher: Arc<SkillsWatcher>,
session_source: SessionSource,
// Captures submitted ops for testing purpose when test mode is enabled.
ops_log: Option<SharedCapturedOps>,
@@ -189,7 +235,7 @@ impl ThreadManager {
config.bundled_skills_enabled(),
restriction_product,
));
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
@@ -204,7 +250,7 @@ impl ThreadManager {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
auth_manager,
session_source,
ops_log: should_use_test_thread_manager_behavior()
@@ -255,7 +301,7 @@ impl ThreadManager {
/*bundled_skills_enabled*/ true,
restriction_product,
));
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
@@ -268,7 +314,7 @@ impl ThreadManager {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
auth_manager,
session_source: SessionSource::Exec,
ops_log: should_use_test_thread_manager_behavior()
@@ -298,10 +344,6 @@ impl ThreadManager {
self.state.mcp_manager.clone()
}
pub fn subscribe_file_watcher(&self) -> broadcast::Receiver<FileWatcherEvent> {
self.state.file_watcher.subscribe()
}
pub fn get_models_manager(&self) -> Arc<ModelsManager> {
self.state.models_manager.clone()
}
@@ -541,20 +583,41 @@ impl ThreadManager {
report
}
/// Fork an existing thread by taking messages up to the given position (not including
/// the message at the given position) and starting a new thread with identical
/// configuration (unless overridden by the caller's `config`). The new thread will have
/// a fresh id. Pass `usize::MAX` to keep the full rollout history.
pub async fn fork_thread(
/// Fork an existing thread by snapshotting rollout history according to
/// `snapshot` and starting a new thread with identical configuration
/// (unless overridden by the caller's `config`). The new thread will have
/// a fresh id.
pub async fn fork_thread<S>(
&self,
nth_user_message: usize,
snapshot: S,
config: Config,
path: PathBuf,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread> {
) -> CodexResult<NewThread>
where
S: Into<ForkSnapshot>,
{
let snapshot = snapshot.into();
let history = RolloutRecorder::get_rollout_history(&path).await?;
let history = truncate_before_nth_user_message(history, nth_user_message);
let snapshot_state = snapshot_turn_state(&history);
let history = match snapshot {
ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message) => {
truncate_before_nth_user_message(history, nth_user_message, &snapshot_state)
}
ForkSnapshot::Interrupted => {
let history = match history {
InitialHistory::New => InitialHistory::New,
InitialHistory::Forked(history) => InitialHistory::Forked(history),
InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history),
};
if snapshot_state.ends_mid_turn {
append_interrupted_boundary(history, snapshot_state.active_turn_id)
} else {
history
}
}
};
Box::pin(self.state.spawn_thread(
config,
history,
@@ -773,7 +836,7 @@ impl ThreadManagerState {
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let watch_registration = self
.file_watcher
.skills_watcher
.register_config(&config, self.skills_manager.as_ref());
let CodexSpawnOk {
codex, thread_id, ..
@@ -784,7 +847,7 @@ impl ThreadManagerState {
skills_manager: Arc::clone(&self.skills_manager),
plugins_manager: Arc::clone(&self.plugins_manager),
mcp_manager: Arc::clone(&self.mcp_manager),
file_watcher: Arc::clone(&self.file_watcher),
skills_watcher: Arc::clone(&self.skills_watcher),
conversation_history: initial_history,
session_source,
agent_control,
@@ -838,11 +901,31 @@ impl ThreadManagerState {
}
}
/// Return a prefix of `items` obtained by cutting strictly before the nth user message
/// (0-based) and all items that follow it.
fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory {
/// Return a fork snapshot cut strictly before the nth user message (0-based).
///
/// Out-of-range values keep the full committed history at a turn boundary, but
/// when the source thread is currently mid-turn they fall back to cutting
/// before the active turn's opening boundary so the fork omits the unfinished
/// suffix entirely.
fn truncate_before_nth_user_message(
history: InitialHistory,
n: usize,
snapshot_state: &SnapshotTurnState,
) -> InitialHistory {
let items: Vec<RolloutItem> = history.get_rollout_items();
let rolled = truncation::truncate_rollout_before_nth_user_message_from_start(&items, n);
let user_positions = truncation::user_message_positions_in_rollout(&items);
let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() {
if let Some(cut_idx) = snapshot_state
.active_turn_start_index
.or_else(|| user_positions.last().copied())
{
items[..cut_idx].to_vec()
} else {
items
}
} else {
truncation::truncate_rollout_before_nth_user_message_from_start(&items, n)
};
if rolled.is_empty() {
InitialHistory::New
@@ -851,6 +934,95 @@ fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> Initia
}
}
#[derive(Debug, Eq, PartialEq)]
struct SnapshotTurnState {
ends_mid_turn: bool,
active_turn_id: Option<String>,
active_turn_start_index: Option<usize>,
}
fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState {
let rollout_items = history.get_rollout_items();
let mut builder = ThreadHistoryBuilder::new();
for item in &rollout_items {
builder.handle_rollout_item(item);
}
let active_turn_id = builder.active_turn_id_if_explicit();
if builder.has_active_turn() && active_turn_id.is_some() {
let active_turn_snapshot = builder.active_turn_snapshot();
if active_turn_snapshot
.as_ref()
.is_some_and(|turn| turn.status != TurnStatus::InProgress)
{
return SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_start_index: None,
};
}
return SnapshotTurnState {
ends_mid_turn: true,
active_turn_id,
active_turn_start_index: builder.active_turn_start_index(),
};
}
let Some(last_user_position) = truncation::user_message_positions_in_rollout(&rollout_items)
.last()
.copied()
else {
return SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_start_index: None,
};
};
// Synthetic fork/resume histories can contain user/assistant response items
// without explicit turn lifecycle events. If the persisted snapshot has no
// terminating boundary after its last user message, treat it as mid-turn.
SnapshotTurnState {
ends_mid_turn: !rollout_items[last_user_position + 1..].iter().any(|item| {
matches!(
item,
RolloutItem::EventMsg(EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_))
)
}),
active_turn_id: None,
active_turn_start_index: None,
}
}
/// Append the same persisted interrupt boundary used by the live interrupt path
/// to an existing fork snapshot after the source thread has been confirmed to
/// be mid-turn.
fn append_interrupted_boundary(history: InitialHistory, turn_id: Option<String>) -> InitialHistory {
let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id,
reason: TurnAbortReason::Interrupted,
}));
match history {
InitialHistory::New => InitialHistory::Forked(vec![
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
aborted_event,
]),
InitialHistory::Forked(mut history) => {
history.push(RolloutItem::ResponseItem(interrupted_turn_history_marker()));
history.push(aborted_event);
InitialHistory::Forked(history)
}
InitialHistory::Resumed(mut resumed) => {
resumed
.history
.push(RolloutItem::ResponseItem(interrupted_turn_history_marker()));
resumed.history.push(aborted_event);
InitialHistory::Forked(resumed.history)
}
}
}
#[cfg(test)]
#[path = "thread_manager_tests.rs"]
mod tests;
+535 -6
View File
@@ -3,11 +3,15 @@ use crate::codex::make_session_and_context;
use crate::config::test_config;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::manager::RefreshStrategy;
use assert_matches::assert_matches;
use crate::rollout::RolloutRecorder;
use crate::tasks::interrupted_turn_history_marker;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use core_test_support::responses::mount_models_once;
use pretty_assertions::assert_eq;
use std::time::Duration;
@@ -38,7 +42,7 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
#[test]
fn drops_from_last_user_only() {
fn truncates_before_requested_user_message() {
let items = [
user_msg("u1"),
assistant_msg("a1"),
@@ -68,7 +72,15 @@ fn drops_from_last_user_only() {
.cloned()
.map(RolloutItem::ResponseItem)
.collect();
let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1);
let truncated = truncate_before_nth_user_message(
InitialHistory::Forked(initial),
1,
&SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_start_index: None,
},
);
let got_items = truncated.get_rollout_items();
let expected_items = vec![
RolloutItem::ResponseItem(items[0].clone()),
@@ -85,8 +97,99 @@ fn drops_from_last_user_only() {
.cloned()
.map(RolloutItem::ResponseItem)
.collect();
let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2);
assert_matches!(truncated2, InitialHistory::New);
let truncated2 = truncate_before_nth_user_message(
InitialHistory::Forked(initial2.clone()),
2,
&SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_start_index: None,
},
);
assert_eq!(
serde_json::to_value(truncated2.get_rollout_items()).unwrap(),
serde_json::to_value(initial2).unwrap()
);
}
#[test]
fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() {
let items = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::ResponseItem(user_msg("u2")),
RolloutItem::ResponseItem(assistant_msg("partial")),
];
let truncated = truncate_before_nth_user_message(
InitialHistory::Forked(items.clone()),
usize::MAX,
&SnapshotTurnState {
ends_mid_turn: true,
active_turn_id: None,
active_turn_start_index: None,
},
);
assert_eq!(
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
serde_json::to_value(items[..2].to_vec()).unwrap()
);
}
#[test]
fn fork_thread_accepts_legacy_usize_snapshot_argument() {
fn assert_legacy_snapshot_callsite(
manager: &ThreadManager,
config: Config,
path: std::path::PathBuf,
) {
let _future = manager.fork_thread(
usize::MAX,
config,
path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
);
}
let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite;
}
#[test]
fn out_of_range_truncation_drops_pre_user_active_turn_prefix() {
let items = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-2".to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
})),
RolloutItem::ResponseItem(user_msg("u2")),
RolloutItem::ResponseItem(assistant_msg("partial")),
];
let snapshot_state = snapshot_turn_state(&InitialHistory::Forked(items.clone()));
assert_eq!(
snapshot_state,
SnapshotTurnState {
ends_mid_turn: true,
active_turn_id: Some("turn-2".to_string()),
active_turn_start_index: Some(2),
},
);
let truncated = truncate_before_nth_user_message(
InitialHistory::Forked(items.clone()),
usize::MAX,
&snapshot_state,
);
assert_eq!(
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
serde_json::to_value(items[..2].to_vec()).unwrap()
);
}
#[tokio::test]
@@ -104,7 +207,15 @@ async fn ignores_session_prefix_messages_when_truncating() {
.map(RolloutItem::ResponseItem)
.collect();
let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1);
let truncated = truncate_before_nth_user_message(
InitialHistory::Forked(rollout_items),
1,
&SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_start_index: None,
},
);
let got_items = truncated.get_rollout_items();
let expected: Vec<RolloutItem> = vec![
@@ -185,3 +296,421 @@ async fn new_uses_configured_openai_provider_for_model_refresh() {
let _ = manager.list_models(RefreshStrategy::Online).await;
assert_eq!(models_mock.requests().len(), 1);
}
#[test]
fn interrupted_fork_snapshot_appends_interrupt_boundary() {
let committed_history =
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]);
assert_eq!(
serde_json::to_value(
append_interrupted_boundary(committed_history, /*turn_id*/ None).get_rollout_items()
)
.expect("serialize interrupted fork history"),
serde_json::to_value(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
})),
])
.expect("serialize expected interrupted fork history"),
);
assert_eq!(
serde_json::to_value(
append_interrupted_boundary(InitialHistory::New, /*turn_id*/ None).get_rollout_items()
)
.expect("serialize interrupted empty fork history"),
serde_json::to_value(vec![
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
})),
])
.expect("serialize expected interrupted empty history"),
);
}
#[test]
fn interrupted_snapshot_is_not_mid_turn() {
let interrupted_history = InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
})),
]);
assert_eq!(
snapshot_turn_state(&interrupted_history),
SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_start_index: None,
},
);
}
#[test]
fn completed_legacy_event_history_is_not_mid_turn() {
let completed_history = InitialHistory::Forked(vec![
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
message: "hello".to_string(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
})),
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
message: "done".to_string(),
phase: None,
memory_citation: None,
})),
]);
assert_eq!(
snapshot_turn_state(&completed_history),
SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_start_index: None,
},
);
}
#[test]
fn mixed_response_and_legacy_user_event_history_is_mid_turn() {
let mixed_history = InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
message: "hello".to_string(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
})),
]);
assert_eq!(
snapshot_turn_state(&mixed_history),
SnapshotTurnState {
ends_mid_turn: true,
active_turn_id: None,
active_turn_start_index: None,
},
);
}
#[tokio::test]
async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
config.codex_home = temp_dir.path().join("codex-home");
config.cwd = config.codex_home.clone();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
CollaborationModesConfig::default(),
);
let source = manager
.resume_thread_with_history(
config.clone(),
InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
]),
auth_manager,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
.expect("create source thread from completed history");
let source_path = source
.thread
.rollout_path()
.expect("source rollout path should exist");
let source_history = RolloutRecorder::get_rollout_history(&source_path)
.await
.expect("read source rollout history");
let source_snapshot_state = snapshot_turn_state(&source_history);
assert!(source_snapshot_state.ends_mid_turn);
let expected_turn_id = source_snapshot_state.active_turn_id.clone();
assert_eq!(expected_turn_id, None);
let forked = manager
.fork_thread(
ForkSnapshot::Interrupted,
config,
source_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
.expect("fork interrupted snapshot");
let forked_path = forked
.thread
.rollout_path()
.expect("forked rollout path should exist");
let history = RolloutRecorder::get_rollout_history(&forked_path)
.await
.expect("read forked rollout history");
assert!(!snapshot_turn_state(&history).ends_mid_turn);
let rollout_items: Vec<_> = history
.get_rollout_items()
.into_iter()
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
let interrupted_marker_json =
serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker()))
.expect("serialize interrupted marker");
let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg(
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: expected_turn_id,
reason: TurnAbortReason::Interrupted,
}),
))
.expect("serialize interrupted abort event");
assert_eq!(
rollout_items
.iter()
.filter(|item| {
serde_json::to_value(item).expect("serialize rollout item")
== interrupted_marker_json
})
.count(),
1,
);
assert_eq!(
rollout_items
.iter()
.filter(|item| {
serde_json::to_value(item).expect("serialize rollout item")
== interrupted_abort_json
})
.count(),
1,
);
}
#[tokio::test]
async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
config.codex_home = temp_dir.path().join("codex-home");
config.cwd = config.codex_home.clone();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
CollaborationModesConfig::default(),
);
let source = manager
.resume_thread_with_history(
config.clone(),
InitialHistory::Forked(vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-explicit".to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
})),
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
]),
auth_manager,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
.expect("create source thread from explicit partial history");
let source_path = source
.thread
.rollout_path()
.expect("source rollout path should exist");
let source_history = RolloutRecorder::get_rollout_history(&source_path)
.await
.expect("read source rollout history");
let source_snapshot_state = snapshot_turn_state(&source_history);
assert_eq!(
source_snapshot_state,
SnapshotTurnState {
ends_mid_turn: true,
active_turn_id: Some("turn-explicit".to_string()),
active_turn_start_index: Some(1),
},
);
let forked = manager
.fork_thread(
ForkSnapshot::Interrupted,
config,
source_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
.expect("fork interrupted snapshot");
let forked_path = forked
.thread
.rollout_path()
.expect("forked rollout path should exist");
let history = RolloutRecorder::get_rollout_history(&forked_path)
.await
.expect("read forked rollout history");
let rollout_items: Vec<_> = history
.get_rollout_items()
.into_iter()
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
assert!(rollout_items.iter().any(|item| {
matches!(
item,
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(turn_id),
reason: TurnAbortReason::Interrupted,
})) if turn_id == "turn-explicit"
)
}));
}
#[tokio::test]
async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
config.codex_home = temp_dir.path().join("codex-home");
config.cwd = config.codex_home.clone();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
CollaborationModesConfig::default(),
);
let source = manager
.resume_thread_with_history(
config.clone(),
InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
]),
auth_manager,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
.expect("create source thread from partial history");
let source_path = source
.thread
.rollout_path()
.expect("source rollout path should exist");
let source_history = RolloutRecorder::get_rollout_history(&source_path)
.await
.expect("read source rollout history");
assert!(snapshot_turn_state(&source_history).ends_mid_turn);
manager.remove_thread(&source.thread_id).await;
let forked = manager
.fork_thread(
ForkSnapshot::Interrupted,
config.clone(),
source_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
.expect("fork interrupted snapshot");
let forked_path = forked
.thread
.rollout_path()
.expect("forked rollout path should exist");
let history = RolloutRecorder::get_rollout_history(&forked_path)
.await
.expect("read forked rollout history");
assert!(!snapshot_turn_state(&history).ends_mid_turn);
let forked_rollout_items: Vec<_> = history
.get_rollout_items()
.into_iter()
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
let interrupted_marker_json =
serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker()))
.expect("serialize interrupted marker");
assert_eq!(
forked_rollout_items
.iter()
.filter(|item| {
serde_json::to_value(item).expect("serialize forked rollout item")
== interrupted_marker_json
})
.count(),
1,
);
manager.remove_thread(&forked.thread_id).await;
let reforked = manager
.fork_thread(
ForkSnapshot::Interrupted,
config,
forked_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
.expect("re-fork interrupted snapshot");
let reforked_path = reforked
.thread
.rollout_path()
.expect("re-forked rollout path should exist");
let reforked_history = RolloutRecorder::get_rollout_history(&reforked_path)
.await
.expect("read re-forked rollout history");
let reforked_rollout_items: Vec<_> = reforked_history
.get_rollout_items()
.into_iter()
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
assert_eq!(
reforked_rollout_items
.iter()
.filter(|item| {
serde_json::to_value(item).expect("serialize re-forked rollout item")
== interrupted_marker_json
})
.count(),
1,
);
assert_eq!(
reforked_rollout_items
.iter()
.filter(|item| {
matches!(
item,
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
reason: TurnAbortReason::Interrupted,
..
}))
)
})
.count(),
1,
);
}
@@ -11,8 +11,6 @@ use crate::client_common::tools::ToolSpec;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::sandboxing::effective_file_system_sandbox_policy;
use crate::sandboxing::merge_permission_profiles;
use crate::tools::context::ApplyPatchToolOutput;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
@@ -35,6 +33,9 @@ use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::BTreeSet;
use std::sync::Arc;
@@ -89,7 +90,7 @@ fn write_permissions_for_paths(file_paths: &[AbsolutePathBuf]) -> Option<Permiss
..Default::default()
})?;
crate::sandboxing::normalize_additional_permissions(permissions).ok()
normalize_additional_permissions(permissions).ok()
}
async fn effective_patch_permissions(
+5 -6
View File
@@ -21,6 +21,9 @@ mod tool_suggest;
pub(crate) mod unified_exec;
mod view_image;
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_utils_absolute_path::AbsolutePathBufGuard;
pub use plan::PLAN_TOOL;
use serde::Deserialize;
@@ -31,8 +34,6 @@ use std::path::PathBuf;
use crate::codex::Session;
use crate::function_tool::FunctionCallError;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::merge_permission_profiles;
use crate::sandboxing::normalize_additional_permissions;
pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler;
pub(crate) use crate::tools::code_mode::CodeModeWaitHandler;
pub use apply_patch::ApplyPatchHandler;
@@ -208,10 +209,8 @@ pub(super) async fn apply_granted_turn_permissions(
);
let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) {
(Some(effective_permissions), Some(granted_permissions)) => {
crate::sandboxing::intersect_permission_profiles(
effective_permissions.clone(),
granted_permissions,
) == *effective_permissions
intersect_permission_profiles(effective_permissions.clone(), granted_permissions)
== *effective_permissions
}
_ => false,
};
@@ -9,17 +9,21 @@ use crate::config::types::ShellEnvironmentPolicy;
use crate::function_tool::FunctionCallError;
use crate::protocol::AgentStatus;
use crate::protocol::AskForApproval;
use crate::protocol::EventMsg;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::Op;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionSource;
use crate::protocol::SubAgentSource;
use crate::protocol::TurnCompleteEvent;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::turn_diff_tracker::TurnDiffTracker;
@@ -80,14 +84,27 @@ fn thread_manager() -> ThreadManager {
)
}
fn inter_agent_message_text(recipient: &str, content: &str) -> String {
serde_json::to_string(&InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from(recipient).expect("recipient path should be valid"),
Vec::new(),
content.to_string(),
))
.expect("inter-agent communication should serialize")
fn history_contains_inter_agent_communication(
history_items: &[ResponseItem],
expected: &InterAgentCommunication,
) -> bool {
history_items.iter().any(|item| {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "assistant" {
return false;
}
content.iter().any(|content_item| match content_item {
ContentItem::OutputText { text } => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.as_ref()
== Some(expected)
}
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
})
})
}
#[derive(Clone, Copy)]
@@ -141,6 +158,18 @@ where
}
}
#[derive(Debug, Deserialize)]
struct ListAgentsResult {
agents: Vec<ListedAgentResult>,
}
#[derive(Debug, Deserialize)]
struct ListedAgentResult {
agent_name: String,
agent_status: serde_json::Value,
last_task_message: Option<String>,
}
#[tokio::test]
async fn handler_rejects_non_function_payloads() {
let (session, turn) = make_session_and_context().await;
@@ -309,7 +338,7 @@ async fn spawn_agent_errors_when_manager_dropped() {
}
#[tokio::test]
async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path() {
async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_path() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
@@ -372,18 +401,18 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
Some("/root/test_process")
);
SendInputHandlerV2
SendMessageHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"send_input",
"send_message",
function_payload(json!({
"target": "test_process",
"message": "continue"
"items": [{"type": "text", "text": "continue"}]
})),
))
.await
.expect("send_input should accept v2 path");
.expect("send_message should accept v2 path");
assert!(manager.captured_ops().iter().any(|(id, op)| {
*id == child_thread_id
@@ -394,58 +423,233 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
&& communication.recipient.as_str() == "/root/test_process"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& !communication.trigger_turn
)
}));
let child_thread = manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let expected_message = inter_agent_message_text("/root/test_process", "continue");
timeout(Duration::from_secs(2), async {
loop {
let history_items = child_thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let recorded = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::OutputText { text }
if text == &expected_message
))
)
});
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } if text == "continue"
))
)
});
if recorded && !saw_user_message {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("v2 send_input should record assistant envelope");
}
#[tokio::test]
async fn multi_agent_v2_send_input_accepts_structured_items() {
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let spawn_output = SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "worker"
})),
))
.await
.expect("spawn_agent should succeed");
let _ = expect_text_output(spawn_output);
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker path should resolve");
let child_thread = manager
.get_thread(agent_id)
.await
.expect("child thread should exist");
let child_turn = child_thread.codex.session.new_default_turn().await;
child_thread
.codex
.session
.send_event(
child_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: child_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
}),
)
.await;
let output = ListAgentsHandlerV2
.handle(invocation(
session,
turn,
"list_agents",
function_payload(json!({})),
))
.await
.expect("list_agents should succeed");
let (content, success) = expect_text_output(output);
let result: ListAgentsResult =
serde_json::from_str(&content).expect("list_agents result should be json");
assert_eq!(result.agents.len(), 1);
assert_eq!(result.agents[0].agent_name, "/root/worker");
assert_eq!(result.agents[0].agent_status, json!({"completed": "done"}));
assert_eq!(
result.agents[0].last_task_message.as_deref(),
Some("inspect this repo")
);
assert_eq!(success, Some(true));
}
#[tokio::test]
async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config.clone());
let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path");
let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path");
session
.services
.agent_control
.spawn_agent_with_metadata(
config.clone(),
vec![UserInput::Text {
text: "research".to_string(),
text_elements: Vec::new(),
}],
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(researcher_path.clone()),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("researcher agent should spawn");
session
.services
.agent_control
.spawn_agent_with_metadata(
config,
vec![UserInput::Text {
text: "build".to_string(),
text_elements: Vec::new(),
}],
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 2,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker agent should spawn");
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(researcher_path),
agent_nickname: None,
agent_role: None,
});
let output = ListAgentsHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"list_agents",
function_payload(json!({
"path_prefix": "worker"
})),
))
.await
.expect("list_agents should succeed");
let (content, _) = expect_text_output(output);
let result: ListAgentsResult =
serde_json::from_str(&content).expect("list_agents result should be json");
assert_eq!(result.agents.len(), 1);
assert_eq!(result.agents[0].agent_name, worker_path.as_str());
assert_eq!(result.agents[0].last_task_message.as_deref(), Some("build"));
}
#[tokio::test]
async fn multi_agent_v2_list_agents_omits_closed_agents() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let spawn_output = SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "worker"
})),
))
.await
.expect("spawn_agent should succeed");
let _ = expect_text_output(spawn_output);
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker path should resolve");
session
.services
.agent_control
.close_agent(agent_id)
.await
.expect("close_agent should succeed");
let output = ListAgentsHandlerV2
.handle(invocation(
session,
turn,
"list_agents",
function_payload(json!({})),
))
.await
.expect("list_agents should succeed");
let (content, _) = expect_text_output(output);
let result: ListAgentsResult =
serde_json::from_str(&content).expect("list_agents result should be json");
assert!(result.agents.is_empty());
}
#[tokio::test]
async fn multi_agent_v2_send_message_rejects_structured_items() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -478,14 +682,10 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker should resolve");
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let invocation = invocation(
session,
turn,
"send_input",
"send_message",
function_payload(json!({
"target": agent_id.to_string(),
"items": [
@@ -495,85 +695,19 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
})),
);
SendInputHandlerV2
.handle(invocation)
.await
.expect("structured items should be accepted in v2");
let expected = Op::UserInput {
items: vec![
UserInput::Mention {
name: "drive".to_string(),
path: "app://google_drive".to_string(),
},
UserInput::Text {
text: "read the folder".to_string(),
text_elements: Vec::new(),
},
],
final_output_json_schema: None,
let Err(err) = SendMessageHandlerV2.handle(invocation).await else {
panic!("structured items should be rejected in v2");
};
let captured = manager
.captured_ops()
.into_iter()
.find(|(id, op)| *id == agent_id && *op == expected);
assert_eq!(captured, Some((agent_id, expected)));
let expected_message = inter_agent_message_text(
"/root/worker",
"[mention:$drive](app://google_drive)\nread the folder",
assert_eq!(
err,
FunctionCallError::RespondToModel(
"send_message only supports text content in MultiAgentV2 for now".to_string()
)
);
timeout(Duration::from_secs(2), async {
loop {
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let recorded_assistant_envelope = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::OutputText { text }
if text == &expected_message
))
)
});
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text }
if text == "read the folder"
|| text == "[mention:$drive](app://google_drive)\nread the folder"
))
)
});
if !recorded_assistant_envelope && saw_user_message {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("structured items should stay on the legacy user-input path");
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() {
async fn multi_agent_v2_send_message_interrupts_busy_child_without_triggering_turn() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -625,19 +759,161 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
)
.await;
SendInputHandlerV2
SendMessageHandlerV2
.handle(invocation(
session,
turn,
"send_input",
session.clone(),
turn.clone(),
"send_message",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"items": [{"type": "text", "text": "continue"}],
"interrupt": true
})),
))
.await
.expect("interrupting v2 send_input should succeed");
.expect("interrupting v2 send_message should succeed");
let ops = manager.captured_ops();
let ops_for_agent: Vec<&Op> = ops
.iter()
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
.collect();
assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt)));
assert!(ops_for_agent.iter().any(|op| {
matches!(
op,
Op::InterAgentCommunication { communication }
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/worker"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& !communication.trigger_turn
)
}));
timeout(Duration::from_secs(5), async {
loop {
if !thread
.codex
.session
.has_queued_response_items_for_next_turn()
.await
{
tokio::time::sleep(Duration::from_millis(10)).await;
continue;
}
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let saw_envelope = history_contains_inter_agent_communication(
&history_items,
&InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"continue".to_string(),
false,
),
);
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } if text == "continue"
))
)
});
if saw_envelope && !saw_user_message {
panic!("send_message should not materialize the envelope into history");
}
if !saw_user_message {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("interrupting v2 send_message should queue the redirected message without a turn");
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "boot worker",
"task_name": "worker"
})),
))
.await
.expect("spawn worker");
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker should resolve");
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
thread
.codex
.session
.spawn_task(
Arc::clone(&active_turn),
vec![UserInput::Text {
text: "working".to_string(),
text_elements: Vec::new(),
}],
NeverEndingTask,
)
.await;
AssignTaskHandlerV2
.handle(invocation(
session,
turn,
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"interrupt": true
})),
))
.await
.expect("interrupting v2 assign_task should succeed");
let ops = manager.captured_ops();
let ops_for_agent: Vec<&Op> = ops
@@ -655,16 +931,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
&& communication.content == "continue"
)
}));
assert!(!ops_for_agent.iter().any(|op| matches!(
op,
Op::UserInput { items, .. }
if items.iter().any(|item| matches!(
item,
UserInput::Text { text, .. } if text == "continue"
))
)));
let expected_message = inter_agent_message_text("/root/worker", "continue");
timeout(Duration::from_secs(5), async {
loop {
let history_items = thread
@@ -674,18 +941,16 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
.await
.raw_items()
.to_vec();
let saw_envelope = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::OutputText { text }
if text == &expected_message
))
)
});
let saw_envelope = history_contains_inter_agent_communication(
&history_items,
&InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"continue".to_string(),
true,
),
);
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
@@ -704,7 +969,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
}
})
.await
.expect("interrupting v2 send_input should preserve the redirected message");
.expect("interrupting v2 assign_task should preserve the redirected message");
let _ = thread
.submit(Op::Shutdown {})
@@ -1406,7 +1671,7 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() {
assert_eq!(
result,
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
status: HashMap::from([(target, AgentStatus::NotFound)]),
message: "Wait completed.".to_string(),
timed_out: false,
}
);
@@ -1574,12 +1839,7 @@ async fn wait_agent_returns_final_status_without_timeout() {
}
#[tokio::test]
async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
}
async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -1609,9 +1869,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
))
.await
.expect("spawn_agent should succeed");
let (content, _) = expect_text_output(spawn_output);
let spawn_result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn result should parse");
let _ = expect_text_output(spawn_output);
let agent_id = session
.services
@@ -1659,13 +1917,67 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
assert_eq!(
result,
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]),
message: "Wait completed.".to_string(),
timed_out: false,
}
);
assert_eq!(success, None);
}
#[tokio::test]
async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config.clone());
let thread = manager.start_thread(config).await.expect("start thread");
let agent_id = thread.thread_id;
let child_turn = thread.thread.codex.session.new_default_turn().await;
thread
.thread
.codex
.session
.send_event(
child_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: child_turn.sub_id.clone(),
last_agent_message: Some("sensitive child output".to_string()),
}),
)
.await;
let output = WaitAgentHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"wait_agent",
function_payload(json!({
"targets": [agent_id.to_string()],
"timeout_ms": 1000
})),
))
.await
.expect("wait_agent should succeed");
let (content, success) = expect_text_output(output);
let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult =
serde_json::from_str(&content).expect("wait_agent result should be json");
assert_eq!(
result,
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
message: "Wait completed.".to_string(),
timed_out: false,
}
);
assert!(!content.contains("sensitive child output"));
assert_eq!(success, None);
}
#[tokio::test]
async fn close_agent_submits_shutdown_and_returns_previous_status() {
let (mut session, turn) = make_session_and_context().await;
@@ -30,10 +30,15 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
pub(crate) use send_input::Handler as SendInputHandler;
pub(crate) use assign_task::Handler as AssignTaskHandler;
pub(crate) use list_agents::Handler as ListAgentsHandler;
pub(crate) use send_message::Handler as SendMessageHandler;
pub(crate) use spawn::Handler as SpawnAgentHandler;
pub(crate) use wait::Handler as WaitAgentHandler;
mod send_input;
mod assign_task;
mod list_agents;
mod message_tool;
mod send_message;
mod spawn;
pub(crate) mod wait;
@@ -0,0 +1,23 @@
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::handle_message_tool;
use super::*;
pub(crate) struct Handler;
#[async_trait]
impl ToolHandler for Handler {
type Output = MessageToolResult;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
handle_message_tool(invocation, MessageDeliveryMode::TriggerTurn).await
}
}
@@ -0,0 +1,68 @@
use super::*;
use crate::agent::control::ListedAgent;
pub(crate) struct Handler;
#[async_trait]
impl ToolHandler for Handler {
type Output = ListAgentsResult;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
..
} = invocation;
let arguments = function_arguments(payload)?;
let args: ListAgentsArgs = parse_arguments(&arguments)?;
session
.services
.agent_control
.register_session_root(session.conversation_id, &turn.session_source);
let agents = session
.services
.agent_control
.list_agents(&turn.session_source, args.path_prefix.as_deref())
.await
.map_err(collab_spawn_error)?;
Ok(ListAgentsResult { agents })
}
}
#[derive(Debug, Deserialize)]
struct ListAgentsArgs {
path_prefix: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ListAgentsResult {
agents: Vec<ListedAgent>,
}
impl ToolOutput for ListAgentsResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "list_agents")
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
tool_output_response_item(call_id, payload, self, Some(true), "list_agents")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "list_agents")
}
}
@@ -0,0 +1,175 @@
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
//!
//! `send_message` and `assign_task` intentionally expose the same input shape and differ only in
//! whether the resulting `InterAgentCommunication` should wake the target immediately.
use super::*;
use codex_protocol::protocol::InterAgentCommunication;
#[derive(Clone, Copy)]
pub(crate) enum MessageDeliveryMode {
QueueOnly,
TriggerTurn,
}
impl MessageDeliveryMode {
/// Returns the model-visible error message for non-text inputs.
fn unsupported_items_error(self) -> &'static str {
match self {
Self::QueueOnly => "send_message only supports text content in MultiAgentV2 for now",
Self::TriggerTurn => "assign_task only supports text content in MultiAgentV2 for now",
}
}
/// Returns whether the produced communication should start a turn immediately.
fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication {
match self {
Self::QueueOnly => InterAgentCommunication {
trigger_turn: false,
..communication
},
Self::TriggerTurn => InterAgentCommunication {
trigger_turn: true,
..communication
},
}
}
}
#[derive(Debug, Deserialize)]
/// Input shared by the MultiAgentV2 `send_message` and `assign_task` tools.
pub(crate) struct MessageToolArgs {
pub(crate) target: String,
pub(crate) items: Vec<UserInput>,
#[serde(default)]
pub(crate) interrupt: bool,
}
#[derive(Debug, Serialize)]
/// Tool result shared by the MultiAgentV2 message-delivery tools.
pub(crate) struct MessageToolResult {
submission_id: String,
}
impl ToolOutput for MessageToolResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "multi_agent_message")
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
tool_output_response_item(call_id, payload, self, Some(true), "multi_agent_message")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "multi_agent_message")
}
}
/// Validates that the tool input is non-empty text-only content and returns its preview string.
fn text_content(
items: &[UserInput],
mode: MessageDeliveryMode,
) -> Result<String, FunctionCallError> {
if items.is_empty() {
return Err(FunctionCallError::RespondToModel(
"Items can't be empty".to_string(),
));
}
if items
.iter()
.all(|item| matches!(item, UserInput::Text { .. }))
{
return Ok(input_preview(items));
}
Err(FunctionCallError::RespondToModel(
mode.unsupported_items_error().to_string(),
))
}
/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`.
pub(crate) async fn handle_message_tool(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
) -> Result<MessageToolResult, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
call_id,
..
} = invocation;
let arguments = function_arguments(payload)?;
let args: MessageToolArgs = parse_arguments(&arguments)?;
let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?;
let prompt = text_content(&args.items, mode)?;
let receiver_agent = session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
if args.interrupt {
session
.services
.agent_control
.interrupt_agent(receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
session
.send_event(
&turn,
CollabAgentInteractionBeginEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id,
prompt: prompt.clone(),
}
.into(),
)
.await;
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
})?;
let communication = InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
);
let result = session
.services
.agent_control
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication))
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err));
let status = session
.services
.agent_control
.get_status(receiver_thread_id)
.await;
session
.send_event(
&turn,
CollabAgentInteractionEndEvent {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id,
receiver_agent_nickname: receiver_agent.agent_nickname,
receiver_agent_role: receiver_agent.agent_role,
prompt,
status,
}
.into(),
)
.await;
let submission_id = result?;
Ok(MessageToolResult { submission_id })
}
@@ -1,145 +0,0 @@
use super::*;
use codex_protocol::protocol::InterAgentCommunication;
pub(crate) struct Handler;
fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool {
items
.iter()
.all(|item| matches!(item, UserInput::Text { .. }))
}
#[async_trait]
impl ToolHandler for Handler {
type Output = SendInputResult;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
call_id,
..
} = invocation;
let arguments = function_arguments(payload)?;
let args: SendInputArgs = parse_arguments(&arguments)?;
let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?;
let input_items = parse_collab_input(args.message, args.items)?;
let prompt = input_preview(&input_items);
let receiver_agent = session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
if args.interrupt {
session
.services
.agent_control
.interrupt_agent(receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
session
.send_event(
&turn,
CollabAgentInteractionBeginEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id,
prompt: prompt.clone(),
}
.into(),
)
.await;
let result = if can_use_v2_inter_agent_instruction(&input_items) {
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel(
"target agent is missing an agent_path".to_string(),
)
})?;
let communication = InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
);
session
.services
.agent_control
.send_inter_agent_communication(receiver_thread_id, communication)
.await
} else {
session
.services
.agent_control
.send_input(receiver_thread_id, input_items)
.await
}
.map_err(|err| collab_agent_error(receiver_thread_id, err));
let status = session
.services
.agent_control
.get_status(receiver_thread_id)
.await;
session
.send_event(
&turn,
CollabAgentInteractionEndEvent {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id,
receiver_agent_nickname: receiver_agent.agent_nickname,
receiver_agent_role: receiver_agent.agent_role,
prompt,
status,
}
.into(),
)
.await;
let submission_id = result?;
Ok(SendInputResult { submission_id })
}
}
#[derive(Debug, Deserialize)]
struct SendInputArgs {
target: String,
message: Option<String>,
items: Option<Vec<UserInput>>,
#[serde(default)]
interrupt: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct SendInputResult {
submission_id: String,
}
impl ToolOutput for SendInputResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "send_input")
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
tool_output_response_item(call_id, payload, self, Some(true), "send_input")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "send_input")
}
}
@@ -0,0 +1,23 @@
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::handle_message_tool;
use super::*;
pub(crate) struct Handler;
#[async_trait]
impl ToolHandler for Handler {
type Output = MessageToolResult;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
handle_message_tool(invocation, MessageDeliveryMode::QueueOnly).await
}
}
@@ -35,21 +35,12 @@ impl ToolHandler for Handler {
let args: WaitArgs = parse_arguments(&arguments)?;
let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?;
let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len());
let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len());
for receiver_thread_id in &receiver_thread_ids {
let agent_metadata = session
.services
.agent_control
.get_agent_metadata(*receiver_thread_id)
.unwrap_or_default();
target_by_thread_id.insert(
*receiver_thread_id,
agent_metadata
.agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| receiver_thread_id.to_string()),
);
receiver_agents.push(CollabAgentRef {
thread_id: *receiver_thread_id,
agent_nickname: agent_metadata.agent_nickname,
@@ -152,18 +143,7 @@ impl ToolHandler for Handler {
let timed_out = statuses.is_empty();
let statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents);
let result = WaitAgentResult {
status: statuses
.into_iter()
.filter_map(|(thread_id, status)| {
target_by_thread_id
.get(&thread_id)
.cloned()
.map(|target| (target, status))
})
.collect(),
timed_out,
};
let result = WaitAgentResult::from_timed_out(timed_out);
session
.send_event(
@@ -191,10 +171,24 @@ struct WaitArgs {
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct WaitAgentResult {
pub(crate) status: HashMap<String, AgentStatus>,
pub(crate) message: String,
pub(crate) timed_out: bool,
}
impl WaitAgentResult {
fn from_timed_out(timed_out: bool) -> Self {
let message = if timed_out {
"Wait timed out."
} else {
"Wait completed."
};
Self {
message: message.to_string(),
timed_out,
}
}
}
impl ToolOutput for WaitAgentResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "wait_agent")
@@ -1,8 +1,8 @@
use async_trait::async_trait;
use codex_protocol::request_permissions::RequestPermissionsArgs;
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use crate::function_tool::FunctionCallError;
use crate::sandboxing::normalize_additional_permissions;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -310,7 +310,7 @@ fn verified_plugin_suggestion_completed(
.list_marketplaces_for_config(config, &[])
.ok()
.into_iter()
.flatten()
.flat_map(|outcome| outcome.marketplaces)
.flat_map(|marketplace| marketplace.plugins.into_iter())
.any(|plugin| plugin.id == tool_id && plugin.installed)
}
+26 -20
View File
@@ -39,14 +39,16 @@ use crate::exec::ExecExpiration;
use crate::exec_env::create_env;
use crate::function_tool::FunctionCallError;
use crate::original_image_detail::normalize_output_image_detail;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::sandboxing::SandboxablePreference;
use crate::truncate::TruncationPolicy;
use crate::truncate::truncate_text;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxablePreference;
pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:";
const KERNEL_SOURCE: &str = include_str!("kernel.js");
@@ -1029,21 +1031,6 @@ impl JsReplManager {
);
}
let spec = CommandSpec {
program: node_path.to_string_lossy().to_string(),
args: vec![
"--experimental-vm-modules".to_string(),
kernel_path.to_string_lossy().to_string(),
],
cwd: turn.cwd.clone(),
env,
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
};
let sandbox = SandboxManager::new();
let has_managed_network_requirements = turn
.config
@@ -1058,9 +1045,25 @@ impl JsReplManager {
turn.windows_sandbox_level,
has_managed_network_requirements,
);
let command = SandboxCommand {
program: node_path.to_string_lossy().to_string(),
args: vec![
"--experimental-vm-modules".to_string(),
kernel_path.to_string_lossy().to_string(),
],
cwd: turn.cwd.clone(),
env,
additional_permissions: None,
};
let options = ExecOptions {
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
};
let exec_env = sandbox
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
.transform(SandboxTransformRequest {
command,
policy: &turn.sandbox_policy,
file_system_policy: &turn.file_system_sandbox_policy,
network_policy: turn.network_sandbox_policy,
@@ -1078,6 +1081,9 @@ impl JsReplManager {
.permissions
.windows_sandbox_private_desktop,
})
.map(|request| {
crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options)
})
.map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?;
let mut cmd =
+5 -5
View File
@@ -12,7 +12,6 @@ use crate::exec::ExecToolCallOutput;
use crate::guardian::GUARDIAN_REJECTION_MESSAGE;
use crate::guardian::routes_approval_to_guardian;
use crate::network_policy_decision::network_approval_context_from_payload;
use crate::sandboxing::SandboxManager;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::begin_network_approval;
@@ -30,6 +29,8 @@ use codex_otel::ToolDecisionSource;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
pub(crate) struct ToolOrchestrator {
sandbox: SandboxManager,
@@ -178,7 +179,7 @@ impl ToolOrchestrator {
.network
.is_some();
let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) {
SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None,
SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None,
SandboxOverride::NoOverride => self.sandbox.select_initial(
&turn_ctx.file_system_sandbox_policy,
turn_ctx.network_sandbox_policy,
@@ -188,8 +189,7 @@ impl ToolOrchestrator {
),
};
// Platform-specific flag gating is handled by SandboxManager::select_initial
// via crate::safety::get_platform_sandbox(..).
// Platform-specific flag gating is handled by SandboxManager::select_initial.
let use_legacy_landlock = turn_ctx.features.use_legacy_landlock();
let initial_attempt = SandboxAttempt {
sandbox: initial_sandbox,
@@ -323,7 +323,7 @@ impl ToolOrchestrator {
}
let escalated_attempt = SandboxAttempt {
sandbox: crate::exec::SandboxType::None,
sandbox: SandboxType::None,
policy: &turn_ctx.sandbox_policy,
file_system_policy: &turn_ctx.file_system_sandbox_policy,
network_policy: turn_ctx.network_sandbox_policy,
+15 -13
View File
@@ -9,7 +9,7 @@ use crate::exec::ExecToolCallOutput;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::tools::sandboxing::Approvable;
@@ -17,7 +17,6 @@ use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
@@ -28,6 +27,8 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxablePreference;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::future::BoxFuture;
use std::collections::HashMap;
@@ -67,10 +68,10 @@ impl ApplyPatchRuntime {
}
}
fn build_command_spec(
fn build_sandbox_command(
req: &ApplyPatchRequest,
_codex_home: &std::path::Path,
) -> Result<CommandSpec, ToolError> {
) -> Result<SandboxCommand, ToolError> {
let exe = if let Some(path) = &req.codex_exe {
path.clone()
} else {
@@ -85,21 +86,16 @@ impl ApplyPatchRuntime {
})?
}
};
let program = exe.to_string_lossy().to_string();
Ok(CommandSpec {
program,
Ok(SandboxCommand {
program: exe.to_string_lossy().to_string(),
args: vec![
CODEX_CORE_APPLY_PATCH_ARG1.to_string(),
req.action.patch.clone(),
],
cwd: req.action.cwd.clone(),
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
// Run apply_patch with a minimal environment for determinism and to avoid leaks.
env: HashMap::new(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: None,
})
}
@@ -206,9 +202,15 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let spec = Self::build_command_spec(req, &ctx.turn.config.codex_home)?;
let command = Self::build_sandbox_command(req, &ctx.turn.config.codex_home)?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: req.sandbox_permissions,
justification: None,
};
let env = attempt
.env_for(spec, /*network*/ None)
.env_for(command, options, /*network*/ None)
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(env, Self::stdout_stream(ctx))
.await
+5 -15
View File
@@ -4,15 +4,12 @@ Module: runtimes
Concrete ToolRuntime implementations for specific tools. Each runtime stays
small and focused and reuses the orchestrator for approvals + sandbox + retry.
*/
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::path_utils;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::skills::SkillMetadata;
use crate::tools::sandboxing::ToolError;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::SandboxCommand;
use std::collections::HashMap;
use std::path::Path;
@@ -28,30 +25,23 @@ pub(crate) struct ExecveSessionApproval {
pub skill: Option<SkillMetadata>,
}
/// Shared helper to construct a CommandSpec from a tokenized command line.
/// Shared helper to construct sandbox transform inputs from a tokenized command line.
/// Validates that at least a program is present.
pub(crate) fn build_command_spec(
pub(crate) fn build_sandbox_command(
command: &[String],
cwd: &Path,
env: &HashMap<String, String>,
expiration: ExecExpiration,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<PermissionProfile>,
justification: Option<String>,
) -> Result<CommandSpec, ToolError> {
) -> Result<SandboxCommand, ToolError> {
let (program, args) = command
.split_first()
.ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?;
Ok(CommandSpec {
Ok(SandboxCommand {
program: program.clone(),
args: args.to_vec(),
cwd: cwd.to_path_buf(),
env: env.clone(),
expiration,
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions,
additional_permissions,
justification,
})
}
+13 -8
View File
@@ -2,24 +2,26 @@
Runtime: shell
Executes shell requests under the orchestrator: asks for approval when needed,
builds a CommandSpec, and runs it under the current SandboxAttempt.
builds sandbox transform inputs, and runs them under the current SandboxAttempt.
*/
#[cfg(unix)]
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecToolCallOutput;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::powershell::prefix_powershell_script_with_utf8;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_command_spec;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
@@ -27,7 +29,6 @@ use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
@@ -36,6 +37,7 @@ use crate::tools::sandboxing::with_cached_approval;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxablePreference;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -243,17 +245,20 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
}
}
let spec = build_command_spec(
let command = build_sandbox_command(
&command,
&req.cwd,
&req.env,
req.timeout_ms.into(),
req.sandbox_permissions,
req.additional_permissions.clone(),
req.justification.clone(),
)?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: req.sandbox_permissions,
justification: req.justification.clone(),
};
let env = attempt
.env_for(spec, req.network.as_ref())
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(env, Self::stdout_stream(ctx))
.await
@@ -4,19 +4,18 @@ use crate::error::SandboxErr;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::skills::SkillMetadata;
use crate::tools::runtimes::ExecveSessionApproval;
use crate::tools::runtimes::build_command_spec;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use codex_execpolicy::Decision;
@@ -35,6 +34,11 @@ use codex_protocol::protocol::ExecApprovalRequestSkillMetadata;
use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
use codex_shell_escalation::EscalateServer;
@@ -107,17 +111,20 @@ pub(super) async fn try_run_zsh_fork(
return Ok(None);
}
let spec = build_command_spec(
let command = build_sandbox_command(
command,
&req.cwd,
&req.env,
req.timeout_ms.into(),
req.sandbox_permissions,
req.additional_permissions.clone(),
req.justification.clone(),
)?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: req.sandbox_permissions,
justification: req.justification.clone(),
};
let sandbox_exec_request = attempt
.env_for(spec, req.network.as_ref())
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
let crate::sandboxing::ExecRequest {
command,
@@ -892,7 +899,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
let mut exec_env = self.env.clone();
// `env_overlay` comes from `EscalationSession::env()`, so merge only the
// wrapper/socket variables into the base shell environment.
for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER", "BASH_EXEC_WRAPPER"] {
for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER"] {
if let Some(value) = env_overlay.get(var) {
exec_env.insert(var.to_string(), value.clone());
}
@@ -1029,7 +1036,7 @@ impl CoreShellCommandExecutor {
let (program, args) = command
.split_first()
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
let sandbox_manager = crate::sandboxing::SandboxManager::new();
let sandbox_manager = SandboxManager::new();
let sandbox = sandbox_manager.select_initial(
file_system_sandbox_policy,
network_sandbox_policy,
@@ -1037,37 +1044,42 @@ impl CoreShellCommandExecutor {
self.windows_sandbox_level,
self.network.is_some(),
);
let sandbox_permissions = if additional_permissions.is_some() {
SandboxPermissions::WithAdditionalPermissions
} else {
SandboxPermissions::UseDefault
};
let command = SandboxCommand {
program: program.clone(),
args: args.to_vec(),
cwd: workdir.to_path_buf(),
env,
additional_permissions,
};
let options = ExecOptions {
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions,
justification: self.justification.clone(),
};
let exec_request = sandbox_manager.transform(SandboxTransformRequest {
command,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox,
enforce_managed_network: self.network.is_some(),
network: self.network.as_ref(),
sandbox_policy_cwd: &self.sandbox_policy_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
use_legacy_landlock: self.use_legacy_landlock,
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: false,
})?;
let mut exec_request =
sandbox_manager.transform(crate::sandboxing::SandboxTransformRequest {
spec: crate::sandboxing::CommandSpec {
program: program.clone(),
args: args.to_vec(),
cwd: workdir.to_path_buf(),
env,
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: if additional_permissions.is_some() {
SandboxPermissions::WithAdditionalPermissions
} else {
SandboxPermissions::UseDefault
},
additional_permissions,
justification: self.justification.clone(),
},
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox,
enforce_managed_network: self.network.is_some(),
network: self.network.as_ref(),
sandbox_policy_cwd: &self.sandbox_policy_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
use_legacy_landlock: self.use_legacy_landlock,
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: false,
})?;
crate::sandboxing::ExecRequest::from_sandbox_exec_request(exec_request, options);
if let Some(network) = exec_request.network.as_ref() {
network.apply_to_env(&mut exec_request.env);
}
@@ -14,14 +14,11 @@ use crate::config::Constrained;
use crate::config::Permissions;
#[cfg(target_os = "macos")]
use crate::config::types::ShellEnvironmentPolicy;
use crate::exec::SandboxType;
use crate::protocol::AskForApproval;
use crate::protocol::GranularApprovalConfig;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::SandboxPermissions;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use crate::skills::SkillMetadata;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
@@ -40,6 +37,9 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SkillScope;
use codex_sandboxing::SandboxType;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use codex_shell_escalation::EscalationExecution;
use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::ExecResult;
@@ -7,16 +7,18 @@ the process manager to spawn PTYs once an ExecRequest is prepared.
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::error::CodexErr;
use crate::error::SandboxErr;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::powershell::prefix_powershell_script_with_utf8;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_command_spec;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::shell::zsh_fork_backend;
use crate::tools::sandboxing::Approvable;
@@ -25,7 +27,6 @@ use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
@@ -39,6 +40,7 @@ use crate::unified_exec::UnifiedExecProcessManager;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxablePreference;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -210,18 +212,17 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
network.apply_to_env(&mut env);
}
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
let spec = build_command_spec(
&command,
&req.cwd,
&env,
ExecExpiration::DefaultTimeout,
req.sandbox_permissions,
req.additional_permissions.clone(),
req.justification.clone(),
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let options = ExecOptions {
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: req.sandbox_permissions,
justification: req.justification.clone(),
};
let exec_env = attempt
.env_for(spec, req.network.as_ref())
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
match zsh_fork_backend::maybe_prepare_unified_exec(
req,
@@ -258,18 +259,17 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
}
}
}
let spec = build_command_spec(
&command,
&req.cwd,
&env,
ExecExpiration::DefaultTimeout,
req.sandbox_permissions,
req.additional_permissions.clone(),
req.justification.clone(),
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let options = ExecOptions {
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
sandbox_permissions: req.sandbox_permissions,
justification: req.justification.clone(),
};
let exec_env = attempt
.env_for(spec, req.network.as_ref())
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env, req.tty, Box::new(NoopSpawnLifecycle))
+15 -16
View File
@@ -9,10 +9,8 @@ use crate::codex::TurnContext;
use crate::error::CodexErr;
#[cfg(test)]
use crate::protocol::SandboxPolicy;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::SandboxTransformError;
use crate::state::SessionServices;
use crate::tools::network_approval::NetworkApprovalSpec;
use codex_network_proxy::NetworkProxy;
@@ -23,6 +21,12 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformError;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use futures::Future;
use futures::future::BoxFuture;
use serde::Serialize;
@@ -280,15 +284,6 @@ pub(crate) trait Approvable<Req> {
) -> BoxFuture<'a, ReviewDecision>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SandboxablePreference {
Auto,
#[allow(dead_code)] // Will be used by later tools.
Require,
#[allow(dead_code)] // Will be used by later tools.
Forbid,
}
pub(crate) trait Sandboxable {
fn sandbox_preference(&self) -> SandboxablePreference;
fn escalate_on_failure(&self) -> bool {
@@ -323,7 +318,7 @@ pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
}
pub(crate) struct SandboxAttempt<'a> {
pub sandbox: crate::exec::SandboxType,
pub sandbox: SandboxType,
pub policy: &'a crate::protocol::SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
@@ -339,12 +334,13 @@ pub(crate) struct SandboxAttempt<'a> {
impl<'a> SandboxAttempt<'a> {
pub fn env_for(
&self,
spec: CommandSpec,
command: SandboxCommand,
options: ExecOptions,
network: Option<&NetworkProxy>,
) -> Result<crate::sandboxing::ExecRequest, SandboxTransformError> {
self.manager
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
.transform(SandboxTransformRequest {
command,
policy: self.policy,
file_system_policy: self.file_system_policy,
network_policy: self.network_policy,
@@ -359,6 +355,9 @@ impl<'a> SandboxAttempt<'a> {
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
})
.map(|request| {
crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options)
})
}
}
+201 -8
View File
@@ -167,6 +167,39 @@ fn send_input_output_schema() -> JsonValue {
})
}
fn list_agents_output_schema() -> JsonValue {
json!({
"type": "object",
"properties": {
"agents": {
"type": "array",
"items": {
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "Canonical task name for the agent when available, otherwise the agent id."
},
"agent_status": {
"description": "Last known status of the agent.",
"allOf": [agent_status_output_schema()]
},
"last_task_message": {
"type": ["string", "null"],
"description": "Most recent user or inter-agent instruction received by the agent, when available."
}
},
"required": ["agent_name", "agent_status", "last_task_message"],
"additionalProperties": false
},
"description": "Live agents visible in the current root thread tree."
}
},
"required": ["agents"],
"additionalProperties": false
})
}
fn resume_agent_output_schema() -> JsonValue {
json!({
"type": "object",
@@ -178,7 +211,7 @@ fn resume_agent_output_schema() -> JsonValue {
})
}
fn wait_output_schema() -> JsonValue {
fn wait_output_schema_v1() -> JsonValue {
json!({
"type": "object",
"properties": {
@@ -197,6 +230,24 @@ fn wait_output_schema() -> JsonValue {
})
}
fn wait_output_schema_v2() -> JsonValue {
json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Brief wait summary without the agent's final content."
},
"timed_out": {
"type": "boolean",
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
}
},
"required": ["message", "timed_out"],
"additionalProperties": false
})
}
fn close_agent_output_schema() -> JsonValue {
json!({
"type": "object",
@@ -1397,6 +1448,80 @@ fn create_send_input_tool() -> ToolSpec {
})
}
fn create_send_message_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
fn create_assign_task_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "assign_task".to_string(),
description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
fn create_resume_agent_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
@@ -1422,7 +1547,7 @@ fn create_resume_agent_tool() -> ToolSpec {
})
}
fn create_wait_agent_tool() -> ToolSpec {
fn wait_agent_tool_parameters() -> JsonSchema {
let mut properties = BTreeMap::new();
properties.insert(
"targets".to_string(),
@@ -1443,18 +1568,60 @@ fn create_wait_agent_tool() -> ToolSpec {
},
);
JsonSchema::Object {
properties,
required: Some(vec!["targets".to_string()]),
additional_properties: Some(false.into()),
}
}
fn create_wait_agent_tool_v1() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters(),
output_schema: Some(wait_output_schema_v1()),
})
}
fn create_wait_agent_tool_v2() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters(),
output_schema: Some(wait_output_schema_v2()),
})
}
fn create_list_agents_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"path_prefix".to_string(),
JsonSchema::String {
description: Some(
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
.to_string(),
),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "list_agents".to_string(),
description: "List live agents in the current root thread tree. Optionally filter by task-path prefix."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["targets".to_string()]),
required: None,
additional_properties: Some(false.into()),
},
output_schema: Some(wait_output_schema()),
output_schema: Some(list_agents_output_schema()),
})
}
@@ -2602,7 +2769,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::multi_agents::SendInputHandler;
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
use crate::tools::handlers::multi_agents::WaitAgentHandler;
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use std::sync::Arc;
@@ -2991,10 +3160,22 @@ pub(crate) fn build_specs_with_discoverable_tools(
);
push_tool_spec(
&mut builder,
create_send_input_tool(),
if config.multi_agent_v2 {
create_send_message_tool()
} else {
create_send_input_tool()
},
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
if config.multi_agent_v2 {
push_tool_spec(
&mut builder,
create_assign_task_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
if !config.multi_agent_v2 {
push_tool_spec(
&mut builder,
@@ -3006,7 +3187,11 @@ pub(crate) fn build_specs_with_discoverable_tools(
}
push_tool_spec(
&mut builder,
create_wait_agent_tool(),
if config.multi_agent_v2 {
create_wait_agent_tool_v2()
} else {
create_wait_agent_tool_v1()
},
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
@@ -3017,9 +3202,17 @@ pub(crate) fn build_specs_with_discoverable_tools(
config.code_mode_enabled,
);
if config.multi_agent_v2 {
push_tool_spec(
&mut builder,
create_list_agents_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2));
builder.register_handler("send_input", Arc::new(SendInputHandlerV2));
builder.register_handler("send_message", Arc::new(SendMessageHandlerV2));
builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2));
builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2));
builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2));
} else {
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
builder.register_handler("send_input", Arc::new(SendInputHandler));
+75 -8
View File
@@ -469,7 +469,11 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
create_view_image_tool(config.can_request_original_image_detail),
create_spawn_agent_tool(&config),
create_send_input_tool(),
create_wait_agent_tool(),
if config.multi_agent_v2 {
create_wait_agent_tool_v2()
} else {
create_wait_agent_tool_v1()
},
create_close_agent_tool(),
] {
expected.insert(tool_name(&spec).to_string(), spec);
@@ -521,6 +525,7 @@ fn test_build_specs_collab_tools_enabled() {
&["spawn_agent", "send_input", "wait_agent", "close_agent"],
);
assert_lacks_tool_name(&tools, "spawn_agents_on_csv");
assert_lacks_tool_name(&tools, "list_agents");
}
#[test]
@@ -541,6 +546,17 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(
&tools,
&[
"spawn_agent",
"send_message",
"assign_task",
"wait_agent",
"close_agent",
"list_agents",
],
);
let spawn_agent = find_tool(&tools, "spawn_agent");
let ToolSpec::Function(ResponsesApiTool {
@@ -569,9 +585,9 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
json!(["agent_id", "task_name", "nickname"])
);
let send_input = find_tool(&tools, "send_input");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_input.spec else {
panic!("send_input should be a function tool");
let send_message = find_tool(&tools, "send_message");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else {
panic!("send_message should be a function tool");
};
let JsonSchema::Object {
properties,
@@ -579,10 +595,33 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
..
} = parameters
else {
panic!("send_input should use object params");
panic!("send_message should use object params");
};
assert!(properties.contains_key("target"));
assert_eq!(required.as_ref(), Some(&vec!["target".to_string()]));
assert!(!properties.contains_key("message"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
);
let assign_task = find_tool(&tools, "assign_task");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &assign_task.spec else {
panic!("assign_task should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("assign_task should use object params");
};
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("message"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
);
let wait_agent = find_tool(&tools, "wait_agent");
let ToolSpec::Function(ResponsesApiTool {
@@ -607,9 +646,37 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
.as_ref()
.expect("wait_agent should define output schema");
assert_eq!(
output_schema["properties"]["status"]["description"],
json!("Final statuses keyed by canonical task name when available, otherwise by agent id.")
output_schema["properties"]["message"]["description"],
json!("Brief wait summary without the agent's final content.")
);
let list_agents = find_tool(&tools, "list_agents");
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = &list_agents.spec
else {
panic!("list_agents should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("list_agents should use object params");
};
assert!(properties.contains_key("path_prefix"));
assert_eq!(required.as_ref(), None);
let output_schema = output_schema
.as_ref()
.expect("list_agents should define output schema");
assert_eq!(
output_schema["properties"]["agents"]["items"]["required"],
json!(["agent_name", "agent_status", "last_task_message"])
);
assert_lacks_tool_name(&tools, "send_input");
assert_lacks_tool_name(&tools, "resume_agent");
}
+8 -6
View File
@@ -8,11 +8,11 @@ use std::sync::RwLock;
use serde::Serialize;
use tokio::task::JoinHandle;
use crate::git_info::get_git_remote_urls_assume_git_repo;
use crate::git_info::get_git_repo_root;
use crate::git_info::get_has_changes;
use crate::git_info::get_head_commit_hash;
use crate::sandbox_tags::sandbox_tag;
use codex_git_utils::get_git_remote_urls_assume_git_repo;
use codex_git_utils::get_git_repo_root;
use codex_git_utils::get_has_changes;
use codex_git_utils::get_head_commit_hash;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::SandboxPolicy;
@@ -94,11 +94,12 @@ fn build_turn_metadata_bag(
pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Option<String> {
let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned());
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(cwd),
get_git_remote_urls_assume_git_repo(cwd),
get_has_changes(cwd),
);
let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0);
if latest_git_commit_hash.is_none()
&& associated_remote_urls.is_none()
&& has_changes.is_none()
@@ -231,11 +232,12 @@ impl TurnMetadataState {
}
async fn fetch_workspace_git_metadata(&self) -> WorkspaceGitMetadata {
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(&self.cwd),
get_git_remote_urls_assume_git_repo(&self.cwd),
get_has_changes(&self.cwd),
);
let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0);
WorkspaceGitMetadata {
associated_remote_urls,
+1 -1
View File
@@ -12,7 +12,7 @@
//! Flow at a glance (open process)
//! 1) Build a small request `{ command, cwd }`.
//! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run.
//! 3) Runtime: transform `CommandSpec` -> `ExecRequest` -> spawn PTY.
//! 3) Runtime: transform `SandboxTransformRequest` -> `ExecRequest` -> spawn PTY.
//! 4) If denial, orchestrator retries with `SandboxType::None`.
//! 5) Process handle is returned with streaming output + metadata.
//!
+1 -1
View File
@@ -13,11 +13,11 @@ use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StreamOutput;
use crate::exec::is_likely_sandbox_denied;
use crate::truncate::TruncationPolicy;
use crate::truncate::formatted_truncate_text;
use codex_sandboxing::SandboxType;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::SpawnedPty;