Merge remote-tracking branch 'origin/rhan/surface-updates' into rhan/emittance

# Conflicts:
#	codex-rs/core/src/codex.rs
This commit is contained in:
Roy Han
2026-03-23 12:31:34 -07:00
208 changed files with 12580 additions and 5470 deletions
+1
View File
@@ -31,6 +31,7 @@ codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
codex-async-utils = { workspace = true }
codex-code-mode = { workspace = true }
codex-connectors = { workspace = true }
codex-config = { workspace = true }
codex-exec-server = { workspace = true }
+5 -3
View File
@@ -61,9 +61,11 @@ cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the
more specific writable child must reopen under a denied parent.
The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and
falls back to the vendored bubblewrap path otherwise. When `/usr/bin/bwrap` is
missing, Codex also surfaces a startup warning through its normal notification
path instead of printing directly from the sandbox helper.
supports the required argv-rewrite flags, and falls back to the vendored
bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is
missing or too old to support the required flags, Codex also surfaces a startup
warning through its normal notification path instead of printing directly from
the sandbox helper.
### Windows
+6 -6
View File
@@ -425,15 +425,15 @@
"multi_agent": {
"type": "boolean"
},
"multi_agent_v2": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},
@@ -2034,15 +2034,15 @@
"multi_agent": {
"type": "boolean"
},
"multi_agent_v2": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},
+55
View File
@@ -0,0 +1,55 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use codex_protocol::ThreadId;
use std::sync::Arc;
/// Resolves a single tool-facing agent target to a thread id.
pub(crate) async fn resolve_agent_target(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
target: &str,
) -> Result<ThreadId, FunctionCallError> {
register_session_root(session, turn);
if let Ok(thread_id) = ThreadId::from_string(target) {
return Ok(thread_id);
}
session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, target)
.await
.map_err(|err| match err {
crate::error::CodexErr::UnsupportedOperation(message) => {
FunctionCallError::RespondToModel(message)
}
other => FunctionCallError::RespondToModel(other.to_string()),
})
}
/// Resolves multiple tool-facing agent targets to thread ids.
pub(crate) async fn resolve_agent_targets(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
targets: Vec<String>,
) -> Result<Vec<ThreadId>, FunctionCallError> {
if targets.is_empty() {
return Err(FunctionCallError::RespondToModel(
"agent targets must be non-empty".to_string(),
));
}
let mut resolved = Vec::with_capacity(targets.len());
for target in &targets {
resolved.push(resolve_agent_target(session, turn, target).await?);
}
Ok(resolved)
}
fn register_session_root(session: &Arc<Session>, turn: &Arc<TurnContext>) {
session
.services
.agent_control
.register_session_root(session.conversation_id, &turn.session_source);
}
+248 -80
View File
@@ -1,5 +1,8 @@
use crate::agent::AgentStatus;
use crate::agent::guards::Guards;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
use crate::agent::registry::AgentMetadata;
use crate::agent::registry::AgentRegistry;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
@@ -15,6 +18,7 @@ use crate::shell_snapshot::ShellSnapshot;
use crate::state_db;
use crate::thread_manager::ThreadManagerState;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
@@ -41,6 +45,13 @@ pub(crate) struct SpawnAgentOptions {
pub(crate) fork_parent_spawn_call_id: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct LiveAgent {
pub(crate) thread_id: ThreadId,
pub(crate) metadata: AgentMetadata,
pub(crate) status: AgentStatus,
}
fn default_agent_nickname_list() -> Vec<&'static str> {
AGENT_NAMES
.lines()
@@ -69,16 +80,16 @@ fn agent_nickname_candidates(
/// Control-plane handle for multi-agent operations.
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
/// spawn new agents and the inter-agent communication layer.
/// An `AgentControl` instance is shared per "user session" which means the same `AgentControl`
/// is used for every sub-agent spawned by Codex. By doing so, we make sure the guards are
/// scoped to a user session.
/// An `AgentControl` instance is intended to be created at most once per root thread/session
/// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root,
/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`.
#[derive(Clone, Default)]
pub(crate) struct AgentControl {
/// Weak handle back to the global thread registry/state.
/// This is `Weak` to avoid reference cycles and shadow persistence of the form
/// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`.
manager: Weak<ThreadManagerState>,
state: Arc<Guards>,
state: Arc<AgentRegistry>,
}
impl AgentControl {
@@ -97,17 +108,30 @@ impl AgentControl {
items: Vec<UserInput>,
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
self.spawn_agent_with_options(config, items, session_source, SpawnAgentOptions::default())
.await
Ok(self
.spawn_agent_internal(config, items, session_source, SpawnAgentOptions::default())
.await?
.thread_id)
}
pub(crate) async fn spawn_agent_with_options(
pub(crate) async fn spawn_agent_with_metadata(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
) -> CodexResult<ThreadId> {
) -> CodexResult<LiveAgent> {
self.spawn_agent_internal(config, items, session_source, options)
.await
}
async fn spawn_agent_internal(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
) -> CodexResult<LiveAgent> {
let state = self.upgrade()?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let inherited_shell_snapshot = self
@@ -116,25 +140,26 @@ impl AgentControl {
let inherited_exec_policy = self
.inherited_exec_policy_for_source(&state, session_source.as_ref(), &config)
.await;
let session_source = match session_source {
let (session_source, mut agent_metadata) = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_path,
agent_role,
..
})) => {
let candidate_names = agent_nickname_candidates(&config, agent_role.as_deref());
let candidate_name_refs: Vec<&str> =
candidate_names.iter().map(String::as_str).collect();
let agent_nickname = reservation.reserve_agent_nickname(&candidate_name_refs)?;
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
let (session_source, agent_metadata) = self.prepare_thread_spawn(
&mut reservation,
&config,
parent_thread_id,
depth,
agent_nickname: Some(agent_nickname),
agent_path,
agent_role,
}))
/*preferred_agent_nickname*/ None,
)?;
(Some(session_source), agent_metadata)
}
other => other,
other => (other, AgentMetadata::default()),
};
let notification_source = session_source.clone();
@@ -217,7 +242,8 @@ impl AgentControl {
}
None => state.spawn_new_thread(config, self.clone()).await?,
};
reservation.commit(new_thread.thread_id);
agent_metadata.agent_id = Some(new_thread.thread_id);
reservation.commit(agent_metadata.clone());
// Notify a new thread has been created. This notification will be processed by clients
// to subscribe or drain this newly created thread.
@@ -232,9 +258,22 @@ impl AgentControl {
.await;
self.send_input(new_thread.thread_id, items).await?;
self.maybe_start_completion_watcher(new_thread.thread_id, notification_source);
let child_reference = agent_metadata
.agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| new_thread.thread_id.to_string());
self.maybe_start_completion_watcher(
new_thread.thread_id,
notification_source,
child_reference,
);
Ok(new_thread.thread_id)
Ok(LiveAgent {
thread_id: new_thread.thread_id,
metadata: agent_metadata,
status: self.get_status(new_thread.thread_id).await,
})
}
/// Resume an existing agent thread from a recorded rollout file.
@@ -283,6 +322,7 @@ impl AgentControl {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: child_depth,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
@@ -324,14 +364,14 @@ impl AgentControl {
}
let state = self.upgrade()?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let session_source = match session_source {
let (session_source, agent_metadata) = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
..
agent_path,
agent_role: _,
agent_nickname: _,
}) => {
// Collab resume callers rebuild a placeholder ThreadSpawn source. Rehydrate the
// stored nickname/role from sqlite when available; otherwise leave both unset.
let (resumed_agent_nickname, resumed_agent_role) =
if let Some(state_db_ctx) = state_db::get_state_db(&config).await {
match state_db_ctx.get_thread(thread_id).await {
@@ -341,27 +381,17 @@ impl AgentControl {
} else {
(None, None)
};
let reserved_agent_nickname = resumed_agent_nickname
.as_deref()
.map(|agent_nickname| {
let candidate_names =
agent_nickname_candidates(&config, resumed_agent_role.as_deref());
let candidate_name_refs: Vec<&str> =
candidate_names.iter().map(String::as_str).collect();
reservation.reserve_agent_nickname_with_preference(
&candidate_name_refs,
Some(agent_nickname),
)
})
.transpose()?;
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
self.prepare_thread_spawn(
&mut reservation,
&config,
parent_thread_id,
depth,
agent_nickname: reserved_agent_nickname,
agent_role: resumed_agent_role,
})
agent_path,
resumed_agent_role,
resumed_agent_nickname,
)?
}
other => other,
other => (other, AgentMetadata::default()),
};
let notification_source = session_source.clone();
let inherited_shell_snapshot = self
@@ -393,13 +423,21 @@ impl AgentControl {
inherited_exec_policy,
)
.await?;
reservation.commit(resumed_thread.thread_id);
let mut agent_metadata = agent_metadata;
agent_metadata.agent_id = Some(resumed_thread.thread_id);
reservation.commit(agent_metadata.clone());
// Resumed threads are re-registered in-memory and need the same listener
// attachment path as freshly spawned threads.
state.notify_thread_created(resumed_thread.thread_id);
let child_reference = agent_metadata
.agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| resumed_thread.thread_id.to_string());
self.maybe_start_completion_watcher(
resumed_thread.thread_id,
Some(notification_source.clone()),
child_reference,
);
self.persist_thread_spawn_edge_for_source(
resumed_thread.thread.as_ref(),
@@ -418,20 +456,53 @@ impl AgentControl {
items: Vec<UserInput>,
) -> CodexResult<String> {
let state = self.upgrade()?;
let result = state
.send_op(
agent_id,
Op::UserInput {
items,
final_output_json_schema: None,
},
)
.await;
if matches!(result, Err(CodexErr::InternalAgentDied)) {
let _ = state.remove_thread(&agent_id).await;
self.state.release_spawned_thread(agent_id);
}
result
self.handle_thread_request_result(
agent_id,
&state,
state
.send_op(
agent_id,
Op::UserInput {
items,
final_output_json_schema: None,
},
)
.await,
)
.await
}
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
#[cfg(test)]
pub(crate) async fn append_message(
&self,
agent_id: ThreadId,
message: ResponseItem,
) -> CodexResult<String> {
let state = self.upgrade()?;
self.handle_thread_request_result(
agent_id,
&state,
state.append_message(agent_id, message).await,
)
.await
}
pub(crate) async fn deliver_inter_agent_instruction(
&self,
agent_id: ThreadId,
instruction: InterAgentInstruction,
delivery: InterAgentDelivery,
) -> CodexResult<String> {
let state = self.upgrade()?;
self.handle_thread_request_result(
agent_id,
&state,
state
.deliver_inter_agent_instruction(agent_id, instruction, delivery)
.await,
)
.await
}
/// Interrupt the current task for an existing agent thread.
@@ -440,6 +511,19 @@ impl AgentControl {
state.send_op(agent_id, Op::Interrupt).await
}
async fn handle_thread_request_result(
&self,
agent_id: ThreadId,
state: &Arc<ThreadManagerState>,
result: CodexResult<String>,
) -> CodexResult<String> {
if matches!(result, Err(CodexErr::InternalAgentDied)) {
let _ = state.remove_thread(&agent_id).await;
self.state.release_spawned_thread(agent_id);
}
result
}
/// Submit a shutdown request for a live agent without marking it explicitly closed in
/// persisted spawn-edge state.
pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
@@ -500,21 +584,18 @@ impl AgentControl {
thread.agent_status().await
}
pub(crate) async fn get_agent_nickname_and_role(
pub(crate) fn register_session_root(
&self,
agent_id: ThreadId,
) -> Option<(Option<String>, Option<String>)> {
let Ok(state) = self.upgrade() else {
return None;
};
let Ok(thread) = state.get_thread(agent_id).await else {
return None;
};
let session_source = thread.config_snapshot().await.session_source;
Some((
session_source.get_nickname(),
session_source.get_agent_role(),
))
current_thread_id: ThreadId,
current_session_source: &SessionSource,
) {
if thread_spawn_parent_thread_id(current_session_source).is_none() {
self.state.register_root_thread(current_thread_id);
}
}
pub(crate) fn get_agent_metadata(&self, agent_id: ThreadId) -> Option<AgentMetadata> {
self.state.agent_metadata_for_thread(agent_id)
}
pub(crate) async fn get_agent_config_snapshot(
@@ -530,6 +611,33 @@ impl AgentControl {
Some(thread.config_snapshot().await)
}
pub(crate) async fn resolve_agent_reference(
&self,
_current_thread_id: ThreadId,
current_session_source: &SessionSource,
agent_reference: &str,
) -> CodexResult<ThreadId> {
let current_agent_path = current_session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root);
let agent_path = current_agent_path
.resolve(agent_reference)
.map_err(CodexErr::UnsupportedOperation)?;
if agent_path.is_root() {
return Err(CodexErr::UnsupportedOperation(
"root is not a spawned agent".to_string(),
));
}
if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) {
return Ok(thread_id);
}
Err(CodexErr::UnsupportedOperation(format!(
"live agent path `{}` not found",
agent_path.as_str()
)))
}
/// Subscribe to status updates for `agent_id`, yielding the latest value and changes.
pub(crate) async fn subscribe_status(
&self,
@@ -560,8 +668,13 @@ impl AgentControl {
agents
.into_iter()
.map(|(thread_id, nickname)| {
format_subagent_context_line(&thread_id.to_string(), nickname.as_deref())
.map(|(thread_id, metadata)| {
let reference = metadata
.agent_path
.as_ref()
.map(|agent_path| agent_path.name().to_string())
.unwrap_or_else(|| thread_id.to_string());
format_subagent_context_line(reference.as_str(), metadata.agent_nickname.as_deref())
})
.collect::<Vec<_>>()
.join("\n")
@@ -575,6 +688,7 @@ impl AgentControl {
&self,
child_thread_id: ThreadId,
session_source: Option<SessionSource>,
child_reference: String,
) {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
@@ -610,13 +724,52 @@ impl AgentControl {
};
parent_thread
.inject_user_message_without_turn(format_subagent_notification_message(
&child_thread_id.to_string(),
child_reference.as_str(),
&status,
))
.await;
});
}
#[allow(clippy::too_many_arguments)]
fn prepare_thread_spawn(
&self,
reservation: &mut crate::agent::registry::SpawnReservation,
config: &crate::config::Config,
parent_thread_id: ThreadId,
depth: i32,
agent_path: Option<AgentPath>,
agent_role: Option<String>,
preferred_agent_nickname: Option<String>,
) -> CodexResult<(SessionSource, AgentMetadata)> {
if depth == 1 {
self.state.register_root_thread(parent_thread_id);
}
if let Some(agent_path) = agent_path.as_ref() {
reservation.reserve_agent_path(agent_path)?;
}
let candidate_names = agent_nickname_candidates(config, agent_role.as_deref());
let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect();
let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference(
&candidate_name_refs,
preferred_agent_nickname.as_deref(),
)?);
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_path: agent_path.clone(),
agent_nickname: agent_nickname.clone(),
agent_role: agent_role.clone(),
});
let agent_metadata = AgentMetadata {
agent_id: None,
agent_path,
agent_nickname,
agent_role,
};
Ok((session_source, agent_metadata))
}
fn upgrade(&self) -> CodexResult<Arc<ThreadManagerState>> {
self.manager
.upgrade()
@@ -666,7 +819,7 @@ impl AgentControl {
async fn open_thread_spawn_children(
&self,
parent_thread_id: ThreadId,
) -> CodexResult<Vec<(ThreadId, Option<String>)>> {
) -> CodexResult<Vec<(ThreadId, AgentMetadata)>> {
let mut children_by_parent = self.live_thread_spawn_children().await?;
Ok(children_by_parent
.remove(&parent_thread_id)
@@ -675,9 +828,9 @@ impl AgentControl {
async fn live_thread_spawn_children(
&self,
) -> CodexResult<HashMap<ThreadId, Vec<(ThreadId, Option<String>)>>> {
) -> CodexResult<HashMap<ThreadId, Vec<(ThreadId, AgentMetadata)>>> {
let state = self.upgrade()?;
let mut children_by_parent = HashMap::<ThreadId, Vec<(ThreadId, Option<String>)>>::new();
let mut children_by_parent = HashMap::<ThreadId, Vec<(ThreadId, AgentMetadata)>>::new();
for thread_id in state.list_thread_ids().await {
let Ok(thread) = state.get_thread(thread_id).await else {
@@ -691,11 +844,26 @@ impl AgentControl {
children_by_parent
.entry(parent_thread_id)
.or_default()
.push((thread_id, snapshot.session_source.get_nickname()));
.push((
thread_id,
self.state
.agent_metadata_for_thread(thread_id)
.unwrap_or(AgentMetadata {
agent_id: Some(thread_id),
..Default::default()
}),
));
}
for children in children_by_parent.values_mut() {
children.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string()));
children.sort_by(|left, right| {
left.1
.agent_path
.as_deref()
.unwrap_or_default()
.cmp(right.1.agent_path.as_deref().unwrap_or_default())
.then_with(|| left.0.to_string().cmp(&right.0.to_string()))
});
}
Ok(children_by_parent)
+97 -7
View File
@@ -382,6 +382,61 @@ async fn send_input_submits_user_message() {
assert_eq!(captured, Some(expected));
}
#[tokio::test]
async fn append_message_records_assistant_message() {
let harness = AgentControlHarness::new().await;
let (thread_id, thread) = harness.start_thread().await;
let message =
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests";
let submission_id = harness
.control
.append_message(
thread_id,
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::InputText {
text: message.to_string(),
}],
end_turn: None,
phase: None,
},
)
.await
.expect("append_message should succeed");
assert!(!submission_id.is_empty());
timeout(Duration::from_secs(5), async {
loop {
let history_items = 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::InputText { text } if text == message
))
)
});
if recorded {
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("assistant message should be recorded");
}
#[tokio::test]
async fn spawn_agent_creates_thread_and_sends_prompt() {
let harness = AgentControlHarness::new().await;
@@ -443,12 +498,13 @@ async fn spawn_agent_can_fork_parent_thread_history() {
let child_thread_id = harness
.control
.spawn_agent_with_options(
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
@@ -457,7 +513,8 @@ async fn spawn_agent_can_fork_parent_thread_history() {
},
)
.await
.expect("forked spawn should succeed");
.expect("forked spawn should succeed")
.thread_id;
let child_thread = harness
.manager
@@ -526,12 +583,13 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
let child_thread_id = harness
.control
.spawn_agent_with_options(
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
@@ -540,7 +598,8 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
},
)
.await
.expect("forked spawn should succeed");
.expect("forked spawn should succeed")
.thread_id;
let child_thread = harness
.manager
@@ -596,12 +655,13 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
let child_thread_id = harness
.control
.spawn_agent_with_options(
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
@@ -610,7 +670,8 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
},
)
.await
.expect("forked spawn should flush parent rollout before loading history");
.expect("forked spawn should flush parent rollout before loading history")
.thread_id;
let child_thread = harness
.manager
@@ -855,6 +916,7 @@ async fn spawn_child_completion_notifies_parent_history() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -886,9 +948,11 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
child_thread_id.to_string(),
);
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
@@ -903,7 +967,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
assert_eq!(
history_contains_text(
&history_items,
&format!("\"agent_id\":\"{child_thread_id}\"")
&format!("\"agent_path\":\"{child_thread_id}\"")
),
true
);
@@ -926,6 +990,7 @@ async fn spawn_thread_subagent_gets_random_nickname_in_session_source() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -945,6 +1010,7 @@ async fn spawn_thread_subagent_gets_random_nickname_in_session_source() {
depth,
agent_nickname,
agent_role,
..
}) = snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
@@ -976,6 +1042,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("researcher".to_string()),
})),
@@ -1018,6 +1085,8 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
control,
};
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let agent_path = AgentPath::from_string("/root/explorer".to_string())
.expect("test agent path should be valid");
let child_thread_id = harness
.control
@@ -1027,6 +1096,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(agent_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1095,6 +1165,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(agent_path.clone()),
agent_nickname: None,
agent_role: None,
}),
@@ -1113,14 +1184,17 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: resumed_parent_thread_id,
depth: resumed_depth,
agent_path: resumed_agent_path,
agent_nickname: resumed_nickname,
agent_role: resumed_role,
..
}) = resumed_snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_eq!(resumed_depth, 1);
assert_eq!(resumed_agent_path, Some(agent_path));
assert_eq!(resumed_nickname, Some(original_nickname));
assert_eq!(resumed_role, Some("explorer".to_string()));
@@ -1206,6 +1280,7 @@ async fn shutdown_agent_tree_closes_live_descendants() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1220,6 +1295,7 @@ async fn shutdown_agent_tree_closes_live_descendants() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
@@ -1289,6 +1365,7 @@ async fn shutdown_agent_tree_closes_descendants_when_started_at_child() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1303,6 +1380,7 @@ async fn shutdown_agent_tree_closes_descendants_when_started_at_child() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
@@ -1378,6 +1456,7 @@ async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1392,6 +1471,7 @@ async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
@@ -1471,6 +1551,7 @@ async fn resume_closed_child_reopens_open_descendants() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1485,6 +1566,7 @@ async fn resume_closed_child_reopens_open_descendants() {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
@@ -1524,6 +1606,7 @@ async fn resume_closed_child_reopens_open_descendants() {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
@@ -1565,6 +1648,7 @@ async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdo
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1579,6 +1663,7 @@ async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdo
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
@@ -1654,6 +1739,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1668,6 +1754,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
@@ -1705,6 +1792,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 99,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
}))
@@ -1782,6 +1870,7 @@ async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails()
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
@@ -1796,6 +1885,7 @@ async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails()
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
-243
View File
@@ -1,243 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
#[test]
fn format_agent_nickname_adds_ordinals_after_reset() {
assert_eq!(format_agent_nickname("Plato", 0), "Plato");
assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd");
assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd");
assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th");
assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st");
}
#[test]
fn session_depth_defaults_to_zero_for_root_sources() {
assert_eq!(session_depth(&SessionSource::Cli), 0);
}
#[test]
fn thread_spawn_depth_increments_and_enforces_limit() {
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_nickname: None,
agent_role: None,
});
let child_depth = next_thread_spawn_depth(&session_source);
assert_eq!(child_depth, 2);
assert!(exceeds_thread_spawn_depth_limit(child_depth, 1));
}
#[test]
fn non_thread_spawn_subagents_default_to_depth_zero() {
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
assert_eq!(session_depth(&session_source), 0);
assert_eq!(next_thread_spawn_depth(&session_source), 1);
assert!(!exceeds_thread_spawn_depth_limit(1, 1));
}
#[test]
fn reservation_drop_releases_slot() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
drop(reservation);
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released");
drop(reservation);
}
#[test]
fn commit_holds_slot_until_release() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(thread_id);
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(thread_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after thread removal");
drop(reservation);
}
#[test]
fn release_ignores_unknown_thread_id() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(thread_id);
guards.release_spawned_thread(ThreadId::new());
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(thread_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after real thread removal");
drop(reservation);
}
#[test]
fn release_is_idempotent_for_registered_threads() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let first_id = ThreadId::new();
reservation.commit(first_id);
guards.release_spawned_thread(first_id);
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused");
let second_id = ThreadId::new();
reservation.commit(second_id);
guards.release_spawned_thread(first_id);
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(second_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after second thread removal");
drop(reservation);
}
#[test]
fn failed_spawn_keeps_nickname_marked_used() {
let guards = Arc::new(Guards::default());
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha"])
.expect("reserve agent name");
assert_eq!(agent_nickname, "alpha");
drop(reservation);
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha", "beta"])
.expect("unused name should still be preferred");
assert_eq!(agent_nickname, "beta");
}
#[test]
fn agent_nickname_resets_used_pool_when_exhausted() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha"])
.expect("name should be reused after pool reset");
assert_eq!(second_name, "alpha the 2nd");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn released_nickname_stays_used_until_pool_reset() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha", "beta"])
.expect("released name should still be marked used");
assert_eq!(second_name, "beta");
let second_id = ThreadId::new();
second.commit(second_id);
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["alpha", "beta"])
.expect("pool reset should permit a duplicate");
let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]);
assert!(expected_names.contains(&third_name));
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn repeated_resets_advance_the_ordinal_suffix() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["Plato"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "Plato");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["Plato"])
.expect("reserve second agent name");
let second_id = ThreadId::new();
second.commit(second_id);
assert_eq!(second_name, "Plato the 2nd");
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["Plato"])
.expect("reserve third agent name");
assert_eq!(third_name, "Plato the 3rd");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 2);
}
@@ -0,0 +1,74 @@
use codex_protocol::AgentPath;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum InterAgentDelivery {
CurrentTurn,
NextTurn,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct InterAgentInstruction {
author: AgentPath,
recipient: AgentPath,
other_recipients: Vec<AgentPath>,
content: String,
}
impl InterAgentInstruction {
pub(crate) fn new(
author: AgentPath,
recipient: AgentPath,
other_recipients: Vec<AgentPath>,
content: String,
) -> Self {
Self {
author,
recipient,
other_recipients,
content,
}
}
pub(crate) fn to_response_item(&self) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: self.as_text(),
}],
end_turn: None,
phase: None,
}
}
pub(crate) fn is_message_content(content: &[ContentItem]) -> bool {
content.iter().any(|content_item| match content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
Self::is_instruction_text(text)
}
_ => false,
})
}
fn as_text(&self) -> String {
let other_recipients = self
.other_recipients
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
format!(
"author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}",
self.author, self.recipient, self.content
)
}
fn is_instruction_text(text: &str) -> bool {
text.starts_with("author: ")
&& text.contains("\nrecipient: ")
&& text.contains("\nother_recipients: [")
&& text.contains("]\nContent: ")
}
}
+5 -3
View File
@@ -1,10 +1,12 @@
pub(crate) mod agent_resolver;
pub(crate) mod control;
mod guards;
pub(crate) mod inter_agent_instruction;
mod registry;
pub(crate) mod role;
pub(crate) mod status;
pub(crate) use codex_protocol::protocol::AgentStatus;
pub(crate) use control::AgentControl;
pub(crate) use guards::exceeds_thread_spawn_depth_limit;
pub(crate) use guards::next_thread_spawn_depth;
pub(crate) use registry::exceeds_thread_spawn_depth_limit;
pub(crate) use registry::next_thread_spawn_depth;
pub(crate) use status::agent_status_from_event;
@@ -1,11 +1,13 @@
use crate::error::CodexErr;
use crate::error::Result;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use rand::prelude::IndexedRandom;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::hash_map::Entry;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
@@ -18,19 +20,26 @@ use std::sync::atomic::Ordering;
/// This structure is shared by all agents in the same user session (because the `AgentControl`
/// is).
#[derive(Default)]
pub(crate) struct Guards {
pub(crate) struct AgentRegistry {
active_agents: Mutex<ActiveAgents>,
total_count: AtomicUsize,
}
#[derive(Default)]
struct ActiveAgents {
threads_set: HashSet<ThreadId>,
thread_agent_nicknames: HashMap<ThreadId, String>,
agent_tree: HashMap<String, AgentMetadata>,
used_agent_nicknames: HashSet<String>,
nickname_reset_count: usize,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct AgentMetadata {
pub(crate) agent_id: Option<ThreadId>,
pub(crate) agent_path: Option<AgentPath>,
pub(crate) agent_nickname: Option<String>,
pub(crate) agent_role: Option<String>,
}
fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String {
match nickname_reset_count {
0 => name.to_string(),
@@ -66,7 +75,7 @@ pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bo
depth > max_depth
}
impl Guards {
impl AgentRegistry {
pub(crate) fn reserve_spawn_slot(
self: &Arc<Self>,
max_threads: Option<usize>,
@@ -82,38 +91,83 @@ impl Guards {
state: Arc::clone(self),
active: true,
reserved_agent_nickname: None,
reserved_agent_path: None,
})
}
pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) {
let removed = {
let removed_counted_agent = {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let removed = active_agents.threads_set.remove(&thread_id);
active_agents.thread_agent_nicknames.remove(&thread_id);
removed
let removed_key = active_agents
.agent_tree
.iter()
.find_map(|(key, metadata)| (metadata.agent_id == Some(thread_id)).then_some(key))
.cloned();
removed_key
.and_then(|key| active_agents.agent_tree.remove(key.as_str()))
.is_some_and(|metadata| {
!metadata.agent_path.as_ref().is_some_and(AgentPath::is_root)
})
};
if removed {
if removed_counted_agent {
self.total_count.fetch_sub(1, Ordering::AcqRel);
}
}
fn register_spawned_thread(&self, thread_id: ThreadId, agent_nickname: Option<String>) {
pub(crate) fn register_root_thread(&self, thread_id: ThreadId) {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
active_agents.threads_set.insert(thread_id);
if let Some(agent_nickname) = agent_nickname {
active_agents
.used_agent_nicknames
.insert(agent_nickname.clone());
active_agents
.thread_agent_nicknames
.insert(thread_id, agent_nickname);
active_agents
.agent_tree
.entry(AgentPath::ROOT.to_string())
.or_insert_with(|| AgentMetadata {
agent_id: Some(thread_id),
agent_path: Some(AgentPath::root()),
..Default::default()
});
}
pub(crate) fn agent_id_for_path(&self, agent_path: &AgentPath) -> Option<ThreadId> {
self.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.agent_tree
.get(agent_path.as_str())
.and_then(|metadata| metadata.agent_id)
}
pub(crate) fn agent_metadata_for_thread(&self, thread_id: ThreadId) -> Option<AgentMetadata> {
self.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.agent_tree
.values()
.find(|metadata| metadata.agent_id == Some(thread_id))
.cloned()
}
fn register_spawned_thread(&self, agent_metadata: AgentMetadata) {
let Some(thread_id) = agent_metadata.agent_id else {
return;
};
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = agent_metadata
.agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| format!("thread:{thread_id}"));
if let Some(agent_nickname) = agent_metadata.agent_nickname.clone() {
active_agents.used_agent_nicknames.insert(agent_nickname);
}
active_agents.agent_tree.insert(key, agent_metadata);
}
fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option<String> {
@@ -156,6 +210,39 @@ impl Guards {
Some(agent_nickname)
}
fn reserve_agent_path(&self, agent_path: &AgentPath) -> Result<()> {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match active_agents.agent_tree.entry(agent_path.to_string()) {
Entry::Occupied(_) => Err(CodexErr::UnsupportedOperation(format!(
"agent path `{agent_path}` already exists"
))),
Entry::Vacant(entry) => {
entry.insert(AgentMetadata {
agent_path: Some(agent_path.clone()),
..Default::default()
});
Ok(())
}
}
}
fn release_reserved_agent_path(&self, agent_path: &AgentPath) {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if active_agents
.agent_tree
.get(agent_path.as_str())
.is_some_and(|metadata| metadata.agent_id.is_none())
{
active_agents.agent_tree.remove(agent_path.as_str());
}
}
fn try_increment_spawned(&self, max_threads: usize) -> bool {
let mut current = self.total_count.load(Ordering::Acquire);
loop {
@@ -176,16 +263,13 @@ impl Guards {
}
pub(crate) struct SpawnReservation {
state: Arc<Guards>,
state: Arc<AgentRegistry>,
active: bool,
reserved_agent_nickname: Option<String>,
reserved_agent_path: Option<AgentPath>,
}
impl SpawnReservation {
pub(crate) fn reserve_agent_nickname(&mut self, names: &[&str]) -> Result<String> {
self.reserve_agent_nickname_with_preference(names, /*preferred*/ None)
}
pub(crate) fn reserve_agent_nickname_with_preference(
&mut self,
names: &[&str],
@@ -201,18 +285,16 @@ impl SpawnReservation {
Ok(agent_nickname)
}
pub(crate) fn commit(self, thread_id: ThreadId) {
self.commit_with_agent_nickname(thread_id, /*agent_nickname*/ None);
pub(crate) fn reserve_agent_path(&mut self, agent_path: &AgentPath) -> Result<()> {
self.state.reserve_agent_path(agent_path)?;
self.reserved_agent_path = Some(agent_path.clone());
Ok(())
}
pub(crate) fn commit_with_agent_nickname(
mut self,
thread_id: ThreadId,
agent_nickname: Option<String>,
) {
let agent_nickname = self.reserved_agent_nickname.take().or(agent_nickname);
self.state
.register_spawned_thread(thread_id, agent_nickname);
pub(crate) fn commit(mut self, agent_metadata: AgentMetadata) {
self.reserved_agent_nickname = None;
self.reserved_agent_path = None;
self.state.register_spawned_thread(agent_metadata);
self.active = false;
}
}
@@ -220,11 +302,14 @@ impl SpawnReservation {
impl Drop for SpawnReservation {
fn drop(&mut self) {
if self.active {
if let Some(agent_path) = self.reserved_agent_path.take() {
self.state.release_reserved_agent_path(&agent_path);
}
self.state.total_count.fetch_sub(1, Ordering::AcqRel);
}
}
}
#[cfg(test)]
#[path = "guards_tests.rs"]
#[path = "registry_tests.rs"]
mod tests;
+324
View File
@@ -0,0 +1,324 @@
use super::*;
use codex_protocol::AgentPath;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
fn agent_path(path: &str) -> AgentPath {
AgentPath::try_from(path).expect("valid agent path")
}
fn agent_metadata(thread_id: ThreadId) -> AgentMetadata {
AgentMetadata {
agent_id: Some(thread_id),
..Default::default()
}
}
#[test]
fn format_agent_nickname_adds_ordinals_after_reset() {
assert_eq!(format_agent_nickname("Plato", 0), "Plato");
assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd");
assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd");
assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th");
assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st");
}
#[test]
fn session_depth_defaults_to_zero_for_root_sources() {
assert_eq!(session_depth(&SessionSource::Cli), 0);
}
#[test]
fn thread_spawn_depth_increments_and_enforces_limit() {
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
let child_depth = next_thread_spawn_depth(&session_source);
assert_eq!(child_depth, 2);
assert!(exceeds_thread_spawn_depth_limit(child_depth, 1));
}
#[test]
fn non_thread_spawn_subagents_default_to_depth_zero() {
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
assert_eq!(session_depth(&session_source), 0);
assert_eq!(next_thread_spawn_depth(&session_source), 1);
assert!(!exceeds_thread_spawn_depth_limit(1, 1));
}
#[test]
fn reservation_drop_releases_slot() {
let registry = Arc::new(AgentRegistry::default());
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
drop(reservation);
let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released");
drop(reservation);
}
#[test]
fn commit_holds_slot_until_release() {
let registry = Arc::new(AgentRegistry::default());
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(agent_metadata(thread_id));
let err = match registry.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
registry.release_spawned_thread(thread_id);
let reservation = registry
.reserve_spawn_slot(Some(1))
.expect("slot released after thread removal");
drop(reservation);
}
#[test]
fn release_ignores_unknown_thread_id() {
let registry = Arc::new(AgentRegistry::default());
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(agent_metadata(thread_id));
registry.release_spawned_thread(ThreadId::new());
let err = match registry.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
registry.release_spawned_thread(thread_id);
let reservation = registry
.reserve_spawn_slot(Some(1))
.expect("slot released after real thread removal");
drop(reservation);
}
#[test]
fn release_is_idempotent_for_registered_threads() {
let registry = Arc::new(AgentRegistry::default());
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
let first_id = ThreadId::new();
reservation.commit(agent_metadata(first_id));
registry.release_spawned_thread(first_id);
let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused");
let second_id = ThreadId::new();
reservation.commit(agent_metadata(second_id));
registry.release_spawned_thread(first_id);
let err = match registry.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
registry.release_spawned_thread(second_id);
let reservation = registry
.reserve_spawn_slot(Some(1))
.expect("slot released after second thread removal");
drop(reservation);
}
#[test]
fn failed_spawn_keeps_nickname_marked_used() {
let registry = Arc::new(AgentRegistry::default());
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
.expect("reserve agent name");
assert_eq!(agent_nickname, "alpha");
drop(reservation);
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
.expect("unused name should still be preferred");
assert_eq!(agent_nickname, "beta");
}
#[test]
fn agent_nickname_resets_used_pool_when_exhausted() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.expect("reserve first slot");
let first_name = first
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(agent_metadata(first_id));
assert_eq!(first_name, "alpha");
let mut second = registry
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
.expect("name should be reused after pool reset");
assert_eq!(second_name, "alpha the 2nd");
let active_agents = registry
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn released_nickname_stays_used_until_pool_reset() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.expect("reserve first slot");
let first_name = first
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(agent_metadata(first_id));
assert_eq!(first_name, "alpha");
registry.release_spawned_thread(first_id);
let mut second = registry
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
.expect("released name should still be marked used");
assert_eq!(second_name, "beta");
let second_id = ThreadId::new();
second.commit(agent_metadata(second_id));
registry.release_spawned_thread(second_id);
let mut third = registry
.reserve_spawn_slot(None)
.expect("reserve third slot");
let third_name = third
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
.expect("pool reset should permit a duplicate");
let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]);
assert!(expected_names.contains(&third_name));
let active_agents = registry
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn repeated_resets_advance_the_ordinal_suffix() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.expect("reserve first slot");
let first_name = first
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(agent_metadata(first_id));
assert_eq!(first_name, "Plato");
registry.release_spawned_thread(first_id);
let mut second = registry
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
.expect("reserve second agent name");
let second_id = ThreadId::new();
second.commit(agent_metadata(second_id));
assert_eq!(second_name, "Plato the 2nd");
registry.release_spawned_thread(second_id);
let mut third = registry
.reserve_spawn_slot(None)
.expect("reserve third slot");
let third_name = third
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
.expect("reserve third agent name");
assert_eq!(third_name, "Plato the 3rd");
let active_agents = registry
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 2);
}
#[test]
fn register_root_thread_indexes_root_path() {
let registry = Arc::new(AgentRegistry::default());
let root_thread_id = ThreadId::new();
registry.register_root_thread(root_thread_id);
assert_eq!(
registry.agent_id_for_path(&AgentPath::root()),
Some(root_thread_id)
);
}
#[test]
fn reserved_agent_path_is_released_when_spawn_fails() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.expect("reserve first slot");
first
.reserve_agent_path(&agent_path("/root/researcher"))
.expect("reserve first path");
drop(first);
let mut second = registry
.reserve_spawn_slot(None)
.expect("reserve second slot");
second
.reserve_agent_path(&agent_path("/root/researcher"))
.expect("dropped reservation should free the path");
}
#[test]
fn committed_agent_path_is_indexed_until_release() {
let registry = Arc::new(AgentRegistry::default());
let thread_id = ThreadId::new();
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
reservation
.reserve_agent_path(&agent_path("/root/researcher"))
.expect("reserve path");
reservation.commit(AgentMetadata {
agent_id: Some(thread_id),
agent_path: Some(agent_path("/root/researcher")),
..Default::default()
});
assert_eq!(
registry.agent_id_for_path(&agent_path("/root/researcher")),
Some(thread_id)
);
registry.release_spawned_thread(thread_id);
assert_eq!(
registry.agent_id_for_path(&agent_path("/root/researcher")),
None
);
}
+5 -2
View File
@@ -99,6 +99,7 @@ pub(crate) async fn monitor_action(
sess: &Session,
turn_context: &TurnContext,
action: serde_json::Value,
protection_client_callsite: &'static str,
) -> ArcMonitorOutcome {
let auth = match turn_context.auth_manager.as_ref() {
Some(auth_manager) => match auth_manager.auth().await {
@@ -138,7 +139,8 @@ pub(crate) async fn monitor_action(
return ArcMonitorOutcome::Ok;
}
};
let body = build_arc_monitor_request(sess, turn_context, action).await;
let body =
build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await;
let client = build_reqwest_client();
let mut request = client
.post(&url)
@@ -236,6 +238,7 @@ async fn build_arc_monitor_request(
sess: &Session,
turn_context: &TurnContext,
action: serde_json::Map<String, serde_json::Value>,
protection_client_callsite: &'static str,
) -> ArcMonitorRequest {
let history = sess.clone_history().await;
let mut messages = build_arc_monitor_messages(history.raw_items());
@@ -254,7 +257,7 @@ async fn build_arc_monitor_request(
codex_thread_id: conversation_id.clone(),
codex_turn_id: turn_context.sub_id.clone(),
conversation_id: Some(conversation_id),
protection_client_callsite: None,
protection_client_callsite: Some(protection_client_callsite.to_string()),
},
messages: Some(messages),
input: None,
+6 -1
View File
@@ -182,6 +182,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
&turn_context,
serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
.expect("action should deserialize"),
"normal",
)
.await;
@@ -192,7 +193,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
codex_thread_id: session.conversation_id.to_string(),
codex_turn_id: turn_context.sub_id.clone(),
conversation_id: Some(session.conversation_id.to_string()),
protection_client_callsite: None,
protection_client_callsite: Some("normal".to_string()),
},
messages: Some(vec![
ArcMonitorChatMessage {
@@ -290,6 +291,7 @@ async fn monitor_action_posts_expected_arc_request() {
"codex_thread_id": session.conversation_id.to_string(),
"codex_turn_id": turn_context.sub_id.clone(),
"conversation_id": session.conversation_id.to_string(),
"protection_client_callsite": "normal",
},
"messages": [{
"role": "user",
@@ -325,6 +327,7 @@ async fn monitor_action_posts_expected_arc_request() {
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
"normal",
)
.await;
@@ -383,6 +386,7 @@ async fn monitor_action_uses_env_url_and_token_overrides() {
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
"normal",
)
.await;
@@ -435,6 +439,7 @@ async fn monitor_action_rejects_legacy_response_fields() {
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
"normal",
)
.await;
+131 -57
View File
@@ -189,9 +189,41 @@ mod rollout_reconstruction_tests;
pub enum SteerInputError {
NoActiveTurn(Vec<UserInput>),
ExpectedTurnMismatch { expected: String, actual: String },
ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind },
EmptyInput,
}
impl SteerInputError {
fn to_error_event(&self) -> ErrorEvent {
match self {
Self::NoActiveTurn(_) => ErrorEvent {
message: "no active turn to steer".to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
},
Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent {
message: format!("expected active turn id `{expected}` but found `{actual}`"),
codex_error_info: Some(CodexErrorInfo::BadRequest),
},
Self::ActiveTurnNotSteerable { turn_kind } => {
let turn_kind_label = match turn_kind {
NonSteerableTurnKind::Review => "review",
NonSteerableTurnKind::Compact => "compact",
};
ErrorEvent {
message: format!("cannot steer a {turn_kind_label} turn"),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: *turn_kind,
}),
}
}
Self::EmptyInput => ErrorEvent {
message: "input must not be empty".to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
},
}
}
}
/// Notes from the previous real user turn.
///
/// Conceptually this is the same role that `previous_model` used to fill, but
@@ -336,6 +368,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_readiness::Readiness;
@@ -768,6 +801,7 @@ pub(crate) struct Session {
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
idle_pending_input: Mutex<Vec<ResponseInputItem>>,
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
js_repl: Arc<JsReplHandle>,
@@ -779,6 +813,7 @@ pub(crate) struct TurnSkillsContext {
pub(crate) outcome: Arc<SkillLoadOutcome>,
pub(crate) implicit_invocation_seen_skills: Arc<Mutex<HashSet<String>>>,
}
impl TurnSkillsContext {
pub(crate) fn new(outcome: Arc<SkillLoadOutcome>) -> Self {
Self {
@@ -1865,6 +1900,7 @@ impl Session {
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
idle_pending_input: Mutex::new(Vec::new()),
guardian_review_session: GuardianReviewSessionManager::default(),
services,
js_repl,
@@ -3488,6 +3524,7 @@ impl Session {
DeveloperInstructions::from_policy(
turn_context.sandbox_policy.get(),
turn_context.approval_policy.value(),
turn_context.config.approvals_reviewer,
self.services.exec_policy.current().as_ref(),
&turn_context.cwd,
turn_context
@@ -3907,6 +3944,21 @@ impl Session {
});
}
match active_turn.tasks.first().map(|(_, task)| task.kind) {
Some(crate::state::TaskKind::Regular) => {}
Some(crate::state::TaskKind::Review) => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
});
}
Some(crate::state::TaskKind::Compact) => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
});
}
None => return Err(SteerInputError::NoActiveTurn(input)),
}
let mut input_item: ResponseInputItem = input.into();
if self.enabled(Feature::ItemMetadata) {
stamp_user_message_type_on_input_item(&mut input_item, UserMessageType::PromptSteering);
@@ -3917,7 +3969,7 @@ impl Session {
Ok(active_turn_id.clone())
}
/// Returns the input if there was no task running to inject into
/// Returns the input if there was no task running to inject into.
pub async fn inject_response_items(
&self,
input: Vec<ResponseInputItem>,
@@ -3966,6 +4018,24 @@ impl Session {
}
}
/// 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() {
return;
}
let mut idle_pending_input = self.idle_pending_input.lock().await;
idle_pending_input.extend(items);
}
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
std::mem::take(&mut *self.idle_pending_input.lock().await)
}
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
!self.idle_pending_input.lock().await.is_empty()
}
pub async fn has_pending_input(&self) -> bool {
let active = self.active_turn.lock().await;
match active.as_ref() {
@@ -4587,26 +4657,35 @@ mod handlers {
_ => unreachable!(),
};
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
// new_turn_with_sub_id already emits the error event.
return;
};
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
.await;
current_context.session_telemetry.user_prompt(&items);
// Attempt to inject input into current task.
if let Err(SteerInputError::NoActiveTurn(items)) =
sess.steer_input(items, /*expected_turn_id*/ None).await
match sess
.steer_input(items.clone(), /*expected_turn_id*/ None)
.await
{
sess.refresh_mcp_servers_if_requested(&current_context)
Ok(_) => current_context.session_telemetry.user_prompt(&items),
Err(SteerInputError::NoActiveTurn(items)) => {
current_context.session_telemetry.user_prompt(&items);
sess.refresh_mcp_servers_if_requested(&current_context)
.await;
sess.spawn_task(
Arc::clone(&current_context),
items,
crate::tasks::RegularTask::new(),
)
.await;
sess.spawn_task(
Arc::clone(&current_context),
items,
crate::tasks::RegularTask::new(),
)
.await;
}
Err(err) => {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(err.to_error_event()),
})
.await;
}
}
}
@@ -5445,7 +5524,7 @@ pub(crate) async fn run_turn(
prewarmed_client_session: Option<ModelClientSession>,
cancellation_token: CancellationToken,
) -> Option<String> {
if input.is_empty() {
if input.is_empty() && !sess.has_pending_input().await {
return None;
}
@@ -5572,20 +5651,6 @@ pub(crate) async fn run_turn(
&available_connectors,
&skill_name_counts_lower,
));
// Explicit plugin mentions can make a plugin's enabled apps callable for
// this turn without persisting those connectors as sticky user selections.
let mut turn_enabled_connectors = explicitly_enabled_connectors.clone();
turn_enabled_connectors.extend(
mentioned_plugins
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.filter(|connector_id| {
available_connectors
.iter()
.any(|connector| connector.is_enabled && connector.id == *connector_id)
}),
);
let connector_names_by_id = available_connectors
.iter()
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
@@ -5601,23 +5666,36 @@ pub(crate) async fn run_turn(
})
.collect::<Vec<_>>();
let mut last_agent_message: Option<String> = None;
if run_pending_session_start_hooks(&sess, &turn_context).await {
return last_agent_message;
return None;
}
let user_prompt_submit_outcome =
run_user_prompt_submit_hooks(&sess, &turn_context, UserMessageItem::new(&input).message())
.await;
if user_prompt_submit_outcome.should_stop {
record_additional_contexts(
let additional_contexts = if input.is_empty() {
Vec::new()
} else {
let user_prompt_submit_outcome = run_user_prompt_submit_hooks(
&sess,
&turn_context,
user_prompt_submit_outcome.additional_contexts,
UserMessageItem::new(&input).message(),
)
.await;
return last_agent_message;
}
let additional_contexts = user_prompt_submit_outcome.additional_contexts;
if user_prompt_submit_outcome.should_stop {
record_additional_contexts(
&sess,
&turn_context,
user_prompt_submit_outcome.additional_contexts,
)
.await;
return None;
}
let mut initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
if sess.enabled(Feature::ItemMetadata) {
stamp_user_message_type_on_input_item(&mut initial_input_for_turn, UserMessageType::Prompt);
}
let response_item: ResponseItem = initial_input_for_turn.clone().into();
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
.await;
user_prompt_submit_outcome.additional_contexts
};
sess.services
.analytics_events_client
.track_app_mentioned(tracking.clone(), mentioned_app_invocations);
@@ -5628,23 +5706,17 @@ pub(crate) async fn run_turn(
}
sess.merge_connector_selection(explicitly_enabled_connectors.clone())
.await;
let mut initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
if sess.enabled(Feature::ItemMetadata) {
stamp_user_message_type_on_input_item(&mut initial_input_for_turn, UserMessageType::Prompt);
}
let response_item: ResponseItem = initial_input_for_turn.clone().into();
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
.await;
record_additional_contexts(&sess, &turn_context, additional_contexts).await;
// Track the previous-turn baseline from the regular user-turn path only so
// standalone tasks (compact/shell/review/undo) cannot suppress future
// model/realtime injections.
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
realtime_active: Some(turn_context.realtime_active),
}))
.await;
if !input.is_empty() {
// Track the previous-turn baseline from the regular user-turn path only so
// standalone tasks (compact/shell/review/undo) cannot suppress future
// model/realtime injections.
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
realtime_active: Some(turn_context.realtime_active),
}))
.await;
}
if !skill_items.is_empty() {
sess.record_conversation_items(&turn_context, &skill_items)
@@ -5655,8 +5727,10 @@ pub(crate) async fn run_turn(
.await;
}
let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref());
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
.await;
let mut last_agent_message: Option<String> = None;
let mut stop_hook_active = false;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
@@ -5741,7 +5815,7 @@ pub(crate) async fn run_turn(
&mut client_session,
turn_metadata_header.as_deref(),
sampling_request_input,
&turn_enabled_connectors,
&explicitly_enabled_connectors,
skills_outcome,
&mut server_model_warning_emitted_for_turn,
cancellation_token.child_token(),
@@ -1,4 +1,5 @@
use super::*;
use crate::context_manager::is_user_turn_boundary;
// Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with
// the resume/fork hydration metadata derived from the same replay.
@@ -201,9 +202,12 @@ impl Session {
);
}
}
RolloutItem::ResponseItem(_)
| RolloutItem::EventMsg(_)
| RolloutItem::SessionMeta(_) => {}
RolloutItem::ResponseItem(response_item) => {
let active_segment =
active_segment.get_or_insert_with(ActiveReplaySegment::default);
active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item);
}
RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {}
}
if base_replacement_history.is_some()
@@ -35,6 +35,18 @@ fn assistant_message(text: &str) -> ResponseItem {
}
}
fn inter_agent_assistant_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
#[tokio::test]
async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings()
{
@@ -432,6 +444,96 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
);
}
#[tokio::test]
async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
let (session, turn_context) = make_session_and_context().await;
let first_context_item = turn_context.to_turn_context_item();
let first_turn_id = first_context_item
.turn_id
.clone()
.expect("turn context should have turn_id");
let assistant_turn_id = "assistant-instruction-turn".to_string();
let assistant_turn_context = TurnContextItem {
turn_id: Some(assistant_turn_id.clone()),
..first_context_item.clone()
};
let assistant_instruction = inter_agent_assistant_message(
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
);
let assistant_reply = assistant_message("worker reply");
let rollout_items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
)),
RolloutItem::EventMsg(EventMsg::UserMessage(
codex_protocol::protocol::UserMessageEvent {
message: "turn 1 user".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
},
)),
RolloutItem::TurnContext(first_context_item.clone()),
RolloutItem::ResponseItem(user_message("turn 1 user")),
RolloutItem::ResponseItem(assistant_message("turn 1 assistant")),
RolloutItem::EventMsg(EventMsg::TurnComplete(
codex_protocol::protocol::TurnCompleteEvent {
turn_id: first_turn_id,
last_agent_message: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: assistant_turn_id.clone(),
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
)),
RolloutItem::TurnContext(assistant_turn_context),
RolloutItem::ResponseItem(assistant_instruction),
RolloutItem::ResponseItem(assistant_reply),
RolloutItem::EventMsg(EventMsg::TurnComplete(
codex_protocol::protocol::TurnCompleteEvent {
turn_id: assistant_turn_id,
last_agent_message: None,
},
)),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
)),
];
let reconstructed = session
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
.await;
assert_eq!(
reconstructed.history,
vec![
user_message("turn 1 user"),
assistant_message("turn 1 assistant")
]
);
assert_eq!(
reconstructed.previous_turn_settings,
Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
realtime_active: Some(turn_context.realtime_active),
})
);
assert_eq!(
serde_json::to_value(reconstructed.reference_context_item)
.expect("serialize reconstructed reference context item"),
serde_json::to_value(Some(first_context_item))
.expect("serialize expected reference context item")
);
}
#[tokio::test]
async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding_user_turns() {
let (session, turn_context) = make_session_and_context().await;
+200
View File
@@ -25,6 +25,7 @@ use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::request_permissions::PermissionGrantScope;
@@ -65,17 +66,31 @@ use codex_execpolicy::NetworkRuleProtocol;
use codex_execpolicy::Policy;
use codex_network_proxy::NetworkProxyConfig;
use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::W3cTraceContext;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::tracing::install_test_tracing;
use core_test_support::wait_for_event;
use opentelemetry::trace::TraceContextExt;
use opentelemetry::trace::TraceId;
use std::path::Path;
@@ -645,6 +660,21 @@ fn filter_connectors_for_input_skips_disabled_connectors() {
assert_eq!(selected, Vec::new());
}
#[test]
fn filter_connectors_for_input_skips_plugin_mentions() {
let connectors = vec![make_connector("figma", "Figma")];
let input = vec![user_message("use [@figma](plugin://figma@openai-curated)")];
let explicitly_enabled_connectors = HashSet::new();
let selected = filter_connectors_for_input(
&connectors,
&input,
&explicitly_enabled_connectors,
&HashMap::new(),
);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() {
let connectors = vec![make_connector("calendar", "Calendar")];
@@ -1106,6 +1136,111 @@ async fn record_initial_history_reconstructs_forked_transcript() {
assert_eq!(expected, history.raw_items());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> {
let server = start_mock_server().await;
mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let first_forked_request = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let mut builder = test_codex().with_config(|config| {
config.permissions.approval_policy =
codex_config::Constrained::allow_any(AskForApproval::OnRequest);
});
let initial = builder.build(&server).await?;
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
initial
.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "fork seed".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let mut fork_config = initial.config.clone();
fork_config.permissions.approval_policy =
codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted);
let forked = initial
.thread_manager
.fork_thread(usize::MAX, fork_config, rollout_path, false, None)
.await?;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: forked.session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: Some("Fork turn collaboration instructions.".to_string()),
},
};
forked
.thread
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(AskForApproval::Never),
approvals_reviewer: None,
sandbox_policy: None,
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: Some(collaboration_mode),
personality: None,
})
.await?;
forked
.thread
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "after fork".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = first_forked_request.single_request();
let snapshot = context_snapshot::format_labeled_requests_snapshot(
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
&[("First Forked Turn Request", &request)],
&ContextSnapshotOptions::default()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
.strip_capability_instructions()
.strip_agents_md_user_context(),
);
let mut settings = insta::Settings::clone_current();
settings.set_snapshot_path("snapshots");
settings.set_prepend_module_to_snapshot(false);
settings.bind(|| {
insta::assert_snapshot!(
"codex_core__codex_tests__fork_startup_context_then_first_turn_diff",
snapshot
);
});
Ok(())
}
#[tokio::test]
async fn record_initial_history_forked_hydrates_previous_turn_settings() {
let (session, turn_context) = make_session_and_context().await;
@@ -2553,6 +2688,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
idle_pending_input: Mutex::new(Vec::new()),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
js_repl,
@@ -3352,6 +3488,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
idle_pending_input: Mutex::new(Vec::new()),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
js_repl,
@@ -4467,6 +4604,43 @@ async fn steer_input_enforces_expected_turn_id() {
}
}
#[tokio::test]
async fn steer_input_rejects_non_regular_turns() {
for (task_kind, turn_kind) in [
(TaskKind::Review, NonSteerableTurnKind::Review),
(TaskKind::Compact, NonSteerableTurnKind::Compact),
] {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await;
sess.spawn_task(
turn_context,
input,
NeverEndingTask {
kind: task_kind,
listen_to_cancellation_token: true,
},
)
.await;
let steer_input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let err = sess
.steer_input(steer_input, /*expected_turn_id*/ None)
.await
.expect_err("steering a non-regular turn should fail");
assert_eq!(err, SteerInputError::ActiveTurnNotSteerable { turn_kind });
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
}
#[tokio::test]
async fn steer_input_returns_active_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
@@ -4637,6 +4811,32 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() {
assert_eq!(sess.get_pending_input().await, vec![later, newer]);
}
#[tokio::test]
async fn queued_response_items_for_next_turn_move_into_next_active_turn() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let queued_item = ResponseInputItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::InputText {
text: "queued before wake".to_string(),
}],
};
sess.queue_response_items_for_next_turn(vec![queued_item.clone()])
.await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
assert_eq!(sess.get_pending_input().await, vec![queued_item]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
+88 -13
View File
@@ -1,4 +1,6 @@
use crate::agent::AgentStatus;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
use crate::codex::Codex;
use crate::codex::SteerInputError;
use crate::config::ConstraintResult;
@@ -122,30 +124,91 @@ impl CodexThread {
/// Records a user-role session-prefix message without creating a new user turn boundary.
pub(crate) async fn inject_user_message_without_turn(&self, message: String) {
let pending_item = ResponseInputItem::Message {
let message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: message }],
metadata: None,
end_turn: None,
phase: None,
};
let pending_items = vec![pending_item];
let Err(items_without_active_turn) = self
let pending_item = match pending_message_input_item(&message) {
Ok(pending_item) => pending_item,
Err(err) => {
debug_assert!(false, "session-prefix message append should succeed: {err}");
return;
}
};
if self
.codex
.session
.inject_response_items(pending_items)
.inject_response_items(vec![pending_item])
.await
else {
return;
};
.is_err()
{
let turn_context = self.codex.session.new_default_turn().await;
self.codex
.session
.record_conversation_items(turn_context.as_ref(), &[message])
.await;
}
}
let turn_context = self.codex.session.new_default_turn().await;
let items: Vec<ResponseItem> = items_without_active_turn
.into_iter()
.map(ResponseItem::from)
.collect();
/// Append a prebuilt message to the thread history without treating it as a user turn.
///
/// If the thread already has an active turn, the message is queued as pending input for that
/// turn. Otherwise it is queued at session scope and a regular turn is started so the agent
/// can consume that pending input through the normal turn pipeline.
pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult<String> {
let submission_id = uuid::Uuid::new_v4().to_string();
let pending_item = pending_message_input_item(&message)?;
if let Err(items) = self
.codex
.session
.inject_response_items(vec![pending_item])
.await
{
self.codex
.session
.queue_response_items_for_next_turn(items)
.await;
self.codex
.session
.ensure_task_for_queued_response_items()
.await;
}
Ok(submission_id)
}
pub(crate) async fn deliver_inter_agent_instruction(
&self,
instruction: InterAgentInstruction,
delivery: InterAgentDelivery,
) -> CodexResult<String> {
let message = instruction.to_response_item();
match delivery {
InterAgentDelivery::CurrentTurn => self.append_message(message).await,
InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await,
}
}
/// Queue a prebuilt message so the next turn records it before any submitted user input.
pub(crate) async fn queue_message_for_next_turn(
&self,
message: ResponseItem,
) -> CodexResult<String> {
let submission_id = uuid::Uuid::new_v4().to_string();
let pending_item = pending_message_input_item(&message)?;
self.codex
.session
.record_conversation_items(turn_context.as_ref(), &items)
.queue_response_items_for_next_turn(vec![pending_item])
.await;
self.codex
.session
.ensure_task_for_queued_response_items()
.await;
Ok(submission_id)
}
pub fn rollout_path(&self) -> Option<PathBuf> {
@@ -199,3 +262,15 @@ impl CodexThread {
Ok(*guard)
}
}
fn pending_message_input_item(message: &ResponseItem) -> CodexResult<ResponseInputItem> {
match message {
ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message {
role: role.clone(),
content: content.clone(),
}),
_ => Err(CodexErr::InvalidRequest(
"append_message only supports ResponseItem::Message".to_string(),
)),
}
}
+72 -122
View File
@@ -5582,16 +5582,69 @@ shell_tool = true
Ok(())
}
#[cfg(target_os = "linux")]
#[test]
fn missing_system_bwrap_warning_matches_system_bwrap_presence() {
#[cfg(target_os = "linux")]
assert_eq!(
missing_system_bwrap_warning().is_some(),
!Path::new("/usr/bin/bwrap").is_file()
);
fn system_bwrap_warning_reports_missing_system_bwrap() {
let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap"))
.expect("missing system bwrap should emit a warning");
#[cfg(not(target_os = "linux"))]
assert!(missing_system_bwrap_warning().is_none());
assert!(warning.contains("could not find system bubblewrap"));
}
#[cfg(target_os = "linux")]
#[test]
fn system_bwrap_warning_reports_too_old_system_bwrap() {
let fake_bwrap = write_fake_bwrap(
r#"#!/bin/sh
if [ "$1" = "--help" ]; then
echo 'usage: bwrap [OPTION...] COMMAND'
exit 0
fi
exit 1
"#,
);
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
let warning = system_bwrap_warning_for_path(fake_bwrap_path)
.expect("old system bwrap should emit a warning");
assert!(warning.contains("too old to support `--argv0`"));
}
#[cfg(target_os = "linux")]
#[test]
fn system_bwrap_warning_skips_supported_system_bwrap() {
let fake_bwrap = write_fake_bwrap(
r#"#!/bin/sh
if [ "$1" = "--help" ]; then
echo ' --argv0 PROGRAM'
exit 0
fi
exit 1
"#,
);
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn system_bwrap_warning_is_disabled_off_linux() {
assert!(system_bwrap_warning().is_none());
}
#[cfg(target_os = "linux")]
fn write_fake_bwrap(contents: &str) -> tempfile::TempPath {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::NamedTempFile;
// Linux rejects exec-ing a file that is still open for writing.
let path = NamedTempFile::new().expect("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");
path
}
#[tokio::test]
@@ -5677,7 +5730,7 @@ approvals_reviewer = "guardian_subagent"
}
#[tokio::test]
async fn smart_approvals_alias_is_migrated_to_guardian_approval() -> std::io::Result<()> {
async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
@@ -5692,23 +5745,19 @@ smart_approvals = true
.build()
.await?;
assert!(config.features.enabled(Feature::GuardianApproval));
assert_eq!(config.features.legacy_feature_usages().count(), 0);
assert_eq!(
config.approvals_reviewer,
ApprovalsReviewer::GuardianSubagent
);
assert!(!config.features.enabled(Feature::GuardianApproval));
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
assert!(serialized.contains("guardian_approval = true"));
assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\""));
assert!(!serialized.contains("smart_approvals"));
assert!(serialized.contains("smart_approvals = true"));
assert!(!serialized.contains("guardian_approval"));
assert!(!serialized.contains("approvals_reviewer"));
Ok(())
}
#[tokio::test]
async fn smart_approvals_alias_is_migrated_in_profiles() -> std::io::Result<()> {
async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
@@ -5719,106 +5768,6 @@ smart_approvals = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(config.features.enabled(Feature::GuardianApproval));
assert_eq!(config.features.legacy_feature_usages().count(), 0);
assert_eq!(
config.approvals_reviewer,
ApprovalsReviewer::GuardianSubagent
);
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
assert!(serialized.contains("[profiles.guardian.features]"));
assert!(serialized.contains("guardian_approval = true"));
assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\""));
assert!(!serialized.contains("smart_approvals"));
Ok(())
}
#[tokio::test]
async fn smart_approvals_alias_migration_preserves_disabled_profile_override() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
guardian_approval = true
[profiles.guardian.features]
smart_approvals = false
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.harness_overrides(ConfigOverrides {
config_profile: Some("guardian".to_string()),
..Default::default()
})
.build()
.await?;
assert!(!config.features.enabled(Feature::GuardianApproval));
assert_eq!(config.features.legacy_feature_usages().count(), 0);
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
assert!(serialized.contains("[profiles.guardian.features]"));
assert!(serialized.contains("guardian_approval = false"));
assert!(!serialized.contains("smart_approvals"));
Ok(())
}
#[tokio::test]
async fn smart_approvals_alias_migration_preserves_existing_approvals_reviewer()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approvals_reviewer = "user"
[features]
smart_approvals = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(config.features.enabled(Feature::GuardianApproval));
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
assert!(serialized.contains("guardian_approval = true"));
assert!(serialized.contains("approvals_reviewer = \"user\""));
assert!(!serialized.contains("smart_approvals"));
Ok(())
}
#[tokio::test]
async fn smart_approvals_alias_migration_does_not_override_canonical_disabled_flag()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
guardian_approval = false
smart_approvals = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
@@ -5829,9 +5778,10 @@ smart_approvals = true
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
assert!(serialized.contains("guardian_approval = false"));
assert!(!serialized.contains("approvals_reviewer = \"guardian_subagent\""));
assert!(!serialized.contains("smart_approvals"));
assert!(serialized.contains("[profiles.guardian.features]"));
assert!(serialized.contains("smart_approvals = true"));
assert!(!serialized.contains("guardian_approval"));
assert!(!serialized.contains("approvals_reviewer"));
Ok(())
}
+46 -148
View File
@@ -96,14 +96,16 @@ use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
#[cfg(target_os = "linux")]
use std::process::Command;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
use crate::config::permissions::network_proxy_config_from_profile_network;
use crate::config::profile::ConfigProfile;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
use toml_edit::value;
pub(crate) mod agent_roles;
pub mod edit;
@@ -153,21 +155,46 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
];
#[cfg(target_os = "linux")]
pub fn missing_system_bwrap_warning() -> Option<String> {
if Path::new(SYSTEM_BWRAP_PATH).is_file() {
None
} else {
Some(format!(
"Codex could not find system bubblewrap at {SYSTEM_BWRAP_PATH}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime."
))
}
pub fn system_bwrap_warning() -> Option<String> {
system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH))
}
#[cfg(not(target_os = "linux"))]
pub fn missing_system_bwrap_warning() -> Option<String> {
pub fn system_bwrap_warning() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option<String> {
if !system_bwrap_path.is_file() {
return Some(format!(
"Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
system_bwrap_path.display()
));
}
if system_bwrap_supports_argv0(system_bwrap_path) {
return None;
}
Some(format!(
"Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
system_bwrap_path.display()
))
}
#[cfg(target_os = "linux")]
fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool {
// bubblewrap added `--argv0` in v0.9.0:
// https://github.com/containers/bubblewrap/releases/tag/v0.9.0
let output = match Command::new(system_bwrap_path).arg("--help").output() {
Ok(output) => output,
Err(_) => return false,
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
stdout.contains("--argv0") || stderr.contains("--argv0")
}
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
let trimmed = raw.trim();
@@ -651,9 +678,6 @@ impl ConfigBuilder {
fallback_cwd,
} = self;
let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?;
if let Err(err) = maybe_migrate_smart_approvals_alias(&codex_home).await {
tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias");
}
let cli_overrides = cli_overrides.unwrap_or_default();
let mut harness_overrides = harness_overrides.unwrap_or_default();
let loader_overrides = loader_overrides.unwrap_or_default();
@@ -701,111 +725,6 @@ impl ConfigBuilder {
}
}
fn config_scope_segments(scope: &[String], key: &str) -> Vec<String> {
let mut segments = scope.to_vec();
segments.push(key.to_string());
segments
}
fn feature_scope_segments(scope: &[String], feature_key: &str) -> Vec<String> {
let mut segments = scope.to_vec();
segments.push("features".to_string());
segments.push(feature_key.to_string());
segments
}
fn push_smart_approvals_alias_migration_edits(
edits: &mut Vec<ConfigEdit>,
scope: &[String],
features: &FeaturesToml,
approvals_reviewer_missing: bool,
) {
let Some(alias_enabled) = features.entries.get("smart_approvals").copied() else {
return;
};
let canonical_enabled = features
.entries
.get("guardian_approval")
.copied()
.unwrap_or(alias_enabled);
if !features.entries.contains_key("guardian_approval") {
edits.push(ConfigEdit::SetPath {
segments: feature_scope_segments(scope, "guardian_approval"),
value: value(alias_enabled),
});
}
if canonical_enabled && approvals_reviewer_missing {
edits.push(ConfigEdit::SetPath {
segments: config_scope_segments(scope, "approvals_reviewer"),
value: value(ApprovalsReviewer::GuardianSubagent.to_string()),
});
}
edits.push(ConfigEdit::ClearPath {
segments: feature_scope_segments(scope, "smart_approvals"),
});
}
/// Rewrites the legacy `smart_approvals` feature flag to
/// `guardian_approval` in `config.toml` before normal config loading.
///
/// If the old key is present, this preserves its value by setting
/// `guardian_approval = <alias value>` when the new key is not already present.
/// Because the deprecated flag historically meant "turn guardian review on",
/// this migration also backfills `approvals_reviewer = "guardian_subagent"`
/// in the same scope when that reviewer is not already configured there and the
/// migrated feature value is `true`.
/// In all cases it removes the deprecated `smart_approvals` entry so future
/// loads only see the canonical feature flag name.
async fn maybe_migrate_smart_approvals_alias(codex_home: &Path) -> std::io::Result<bool> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
if !tokio::fs::try_exists(&config_path).await? {
return Ok(false);
}
let config_contents = tokio::fs::read_to_string(&config_path).await?;
let Ok(config_toml) = toml::from_str::<ConfigToml>(&config_contents) else {
return Ok(false);
};
let mut edits = Vec::new();
let root_scope = Vec::new();
if let Some(features) = config_toml.features.as_ref() {
push_smart_approvals_alias_migration_edits(
&mut edits,
&root_scope,
features,
config_toml.approvals_reviewer.is_none(),
);
}
for (profile_name, profile) in &config_toml.profiles {
if let Some(features) = profile.features.as_ref() {
let scope = vec!["profiles".to_string(), profile_name.clone()];
push_smart_approvals_alias_migration_edits(
&mut edits,
&scope,
features,
profile.approvals_reviewer.is_none(),
);
}
}
if edits.is_empty() {
return Ok(false);
}
ConfigEditsBuilder::new(codex_home)
.with_edits(edits)
.apply()
.await
.map_err(|err| {
std::io::Error::other(format!("failed to migrate guardian_approval alias: {err}"))
})?;
Ok(true)
}
impl Config {
/// This is the preferred way to create an instance of [Config].
pub async fn load_with_cli_overrides(
@@ -867,9 +786,6 @@ pub async fn load_config_as_toml_with_cli_overrides(
cwd: &AbsolutePathBuf,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
if let Err(err) = maybe_migrate_smart_approvals_alias(codex_home).await {
tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias");
}
let config_layer_stack = load_config_layers_state(
codex_home,
Some(cwd.clone()),
@@ -1918,29 +1834,6 @@ fn resolve_permission_config_syntax(
})
}
fn add_additional_file_system_writes(
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
additional_writable_roots: &[AbsolutePathBuf],
) {
for path in additional_writable_roots {
let exists = file_system_sandbox_policy.entries.iter().any(|entry| {
matches!(
&entry.path,
codex_protocol::permissions::FileSystemPath::Path { path: existing }
if existing == path && entry.access == codex_protocol::permissions::FileSystemAccessMode::Write
)
});
if !exists {
file_system_sandbox_policy.entries.push(
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path { path: path.clone() },
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
);
}
}
}
/// Optional overrides for user configuration (e.g., from CLI flags).
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
@@ -2309,10 +2202,8 @@ impl Config {
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
add_additional_file_system_writes(
&mut file_system_sandbox_policy,
&additional_writable_roots,
);
file_system_sandbox_policy = file_system_sandbox_policy
.with_additional_writable_roots(&resolved_cwd, &additional_writable_roots);
sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
}
@@ -2663,6 +2554,11 @@ impl Config {
} else {
network.enabled().then_some(network)
};
let helper_readable_roots = get_readable_roots_required_for_codex_runtime(
&codex_home,
zsh_path.as_ref(),
main_execve_wrapper_exe.as_ref(),
);
let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone();
let effective_file_system_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
@@ -2673,6 +2569,8 @@ impl Config {
&resolved_cwd,
)
};
let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy
.with_additional_readable_roots(&resolved_cwd, &helper_readable_roots);
let effective_network_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
network_sandbox_policy
+31
View File
@@ -190,6 +190,37 @@ pub(crate) fn compile_permission_profile(
))
}
/// Returns a list of paths that must be readable by shell tools in order
/// for Codex to function. These should always be added to the
/// `FileSystemSandboxPolicy` for a thread.
pub(crate) fn get_readable_roots_required_for_codex_runtime(
codex_home: &Path,
zsh_path: Option<&PathBuf>,
main_execve_wrapper_exe: Option<&PathBuf>,
) -> Vec<AbsolutePathBuf> {
let arg0_root = AbsolutePathBuf::from_absolute_path(codex_home.join("tmp").join("arg0")).ok();
let zsh_path = zsh_path.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok());
let execve_wrapper_root = main_execve_wrapper_exe.and_then(|path| {
let path = AbsolutePathBuf::from_absolute_path(path).ok()?;
if let Some(arg0_root) = arg0_root.as_ref()
&& path.as_path().starts_with(arg0_root.as_path())
{
path.parent()
} else {
Some(path)
}
});
let mut readable_roots = Vec::new();
if let Some(zsh_path) = zsh_path {
readable_roots.push(zsh_path);
}
if let Some(execve_wrapper_root) = execve_wrapper_root {
readable_roots.push(execve_wrapper_root);
}
readable_roots
}
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
let Some(network) = network else {
return NetworkSandboxPolicy::Restricted;
@@ -1,5 +1,11 @@
use super::*;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use tempfile::TempDir;
#[test]
fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
@@ -7,3 +13,66 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true);
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
}
#[test]
fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let cwd = temp_dir.path().join("workspace");
let codex_home = temp_dir.path().join(".codex");
let zsh_path = temp_dir.path().join("runtime").join("zsh");
let arg0_root = codex_home.join("tmp").join("arg0");
let allowed_arg0_dir = arg0_root.join("codex-arg0-session");
let sibling_arg0_dir = arg0_root.join("codex-arg0-other-session");
let execve_wrapper = allowed_arg0_dir.join("codex-execve-wrapper");
std::fs::create_dir_all(&cwd)?;
std::fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?;
std::fs::create_dir_all(&allowed_arg0_dir)?;
std::fs::create_dir_all(&sibling_arg0_dir)?;
std::fs::write(&zsh_path, "")?;
std::fs::write(&execve_wrapper, "")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::new(),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.clone()),
zsh_path: Some(zsh_path.clone()),
main_execve_wrapper_exe: Some(execve_wrapper),
..Default::default()
},
codex_home,
)?;
let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?;
let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?;
let expected_sibling_arg0_dir = AbsolutePathBuf::try_from(sibling_arg0_dir)?;
let policy = &config.permissions.file_system_sandbox_policy;
assert!(
policy.can_read_path_with_cwd(expected_zsh.as_path(), &cwd),
"expected zsh helper path to be readable, policy: {policy:?}"
);
assert!(
policy.can_read_path_with_cwd(expected_allowed_arg0_dir.as_path(), &cwd),
"expected active arg0 helper dir to be readable, policy: {policy:?}"
);
assert!(
!policy.can_read_path_with_cwd(expected_sibling_arg0_dir.as_path(), &cwd),
"expected sibling arg0 helper dir to remain unreadable, policy: {policy:?}"
);
Ok(())
}
+11 -4
View File
@@ -1,3 +1,4 @@
use crate::agent::inter_agent_instruction::InterAgentInstruction;
use crate::codex::TurnContext;
use crate::context_manager::normalize;
use crate::event_mapping::is_contextual_user_message_content;
@@ -204,9 +205,10 @@ impl ContextManager {
}
}
/// Drop the last `num_turns` user turns from this history.
/// Drop the last `num_turns` instruction turns from this history.
///
/// "User turns" are identified as `ResponseItem::Message` entries whose role is `"user"`.
/// Instruction turns are history messages that should behave like a new prompt boundary:
/// ordinary user messages and structured assistant inter-agent instructions.
///
/// This mirrors thread-rollback semantics:
/// - `num_turns == 0` is a no-op
@@ -248,7 +250,7 @@ impl ContextManager {
}
fn get_non_last_reasoning_items_tokens(&self) -> i64 {
// Get reasoning items excluding all the ones after the last user message.
// Get reasoning items excluding all the ones after the last instruction boundary.
let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else {
return 0;
};
@@ -631,7 +633,12 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
return false;
};
role == "user" && !is_contextual_user_message_content(content)
(role == "user" && !is_contextual_user_message_content(content))
|| (role == "assistant" && is_inter_agent_instruction_content(content))
}
fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool {
InterAgentInstruction::is_message_content(content)
}
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
@@ -39,6 +39,18 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
}
fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn create_history_with_items(items: Vec<ResponseItem>) -> ContextManager {
let mut h = ContextManager::new();
// Use a generous but fixed token budget; tests only rely on truncation
@@ -231,6 +243,35 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item
);
}
#[test]
fn inter_agent_assistant_messages_are_turn_boundaries() {
let item = inter_agent_assistant_msg(
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
);
assert!(is_user_turn_boundary(&item));
}
#[test]
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
let first_turn = user_input_text_msg("first");
let first_reply = assistant_msg("done");
let inter_agent_turn = inter_agent_assistant_msg(
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
);
let inter_agent_reply = assistant_msg("worker reply");
let mut history = create_history_with_items(vec![
first_turn.clone(),
first_reply.clone(),
inter_agent_turn,
inter_agent_reply,
]);
history.drop_last_n_user_turns(1);
assert_eq!(history.raw_items(), &vec![first_turn, first_reply]);
}
#[test]
fn total_token_usage_includes_all_items_after_last_model_generated_item() {
let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]);
@@ -43,6 +43,7 @@ fn build_permissions_update_item(
Some(DeveloperInstructions::from_policy(
next.sandbox_policy.get(),
next.approval_policy.value(),
next.config.approvals_reviewer,
exec_policy,
&next.cwd,
next.features.enabled(Feature::ExecPermissionApprovals),
+1 -1
View File
@@ -72,7 +72,7 @@ fn parse_agent_message(
let mut content: Vec<AgentMessageContent> = Vec::new();
for content_item in message.iter() {
match content_item {
ContentItem::OutputText { text } => {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
content.push(AgentMessageContent::Text { text: text.clone() });
}
_ => {
+37
View File
@@ -97,6 +97,43 @@ fn skips_local_image_label_text() {
}
}
#[test]
fn parses_assistant_message_input_text_for_backward_compatibility() {
let item = ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::InputText {
text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
.to_string(),
}],
end_turn: None,
phase: None,
};
let turn_item = parse_turn_item(&item).expect("expected assistant message turn item");
match turn_item {
TurnItem::AgentMessage(message) => {
let rendered = message
.content
.into_iter()
.map(|content| {
let AgentMessageContent::Text { text } = content;
text
})
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec![
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
.to_string()
]
);
}
other => panic!("expected TurnItem::AgentMessage, got {other:?}"),
}
}
#[test]
fn skips_unnamed_image_label_text() {
let image_url = "data:image/png;base64,abc".to_string();
+14 -3
View File
@@ -549,7 +549,7 @@ pub fn render_decision_for_unmatched_command(
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
// here.
let runtime_sandbox_provides_safety =
let environment_lacks_sandbox_protections =
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. });
// If the command is flagged as dangerous or we have no sandbox protection,
@@ -558,9 +558,20 @@ pub fn render_decision_for_unmatched_command(
// We prefer to prompt the user rather than outright forbid the command,
// but if the user has explicitly disabled prompts, we must
// forbid the command.
if command_might_be_dangerous(command) || runtime_sandbox_provides_safety {
if command_might_be_dangerous(command) || environment_lacks_sandbox_protections {
return match approval_policy {
AskForApproval::Never => Decision::Forbidden,
AskForApproval::Never => {
let sandbox_is_explicitly_disabled = matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
);
if sandbox_is_explicitly_disabled {
// If the sandbox is explicitly disabled, we should allow the command to run
Decision::Allow
} else {
Decision::Forbidden
}
}
AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
+325 -344
View File
@@ -81,6 +81,10 @@ fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::unrestricted()
}
fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::external_sandbox()
}
async fn test_config() -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::default()
@@ -503,39 +507,25 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> {
#[tokio::test]
async fn evaluates_bash_lc_inner_commands() {
let policy_src = r#"
prefix_rule(pattern=["rm"], decision="forbidden")
"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let forbidden_script = vec![
"bash".to_string(),
"-lc".to_string(),
"rm -rf /some/important/folder".to_string(),
];
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &forbidden_script,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="forbidden")"#.to_string()),
command: vec![
"bash".to_string(),
"-lc".to_string(),
"rm -rf /some/important/folder".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
ExecApprovalRequirement::Forbidden {
reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string()
}
);
},
ExecApprovalRequirement::Forbidden {
reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string(),
},
)
.await;
}
#[test]
@@ -558,163 +548,132 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() {
#[tokio::test]
async fn evaluates_heredoc_script_against_prefix_rules() {
let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
];
let requirement = ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()),
command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
}
);
},
)
.await;
}
#[tokio::test]
async fn omits_auto_amendment_for_heredoc_fallback_prompts() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
];
let requirement = ExecPolicyManager::default()
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
"bash".to_string(),
"-lc".to_string(),
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
}
);
},
)
.await;
}
#[tokio::test]
async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
];
let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()];
let requirement = ExecPolicyManager::default()
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
"bash".to_string(),
"-lc".to_string(),
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(requested_prefix.clone()),
})
.await;
assert_eq!(
requirement,
prefix_rule: Some(vec![
"python3".to_string(),
"-m".to_string(),
"pip".to_string(),
]),
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
}
);
},
)
.await;
}
#[tokio::test]
async fn justification_is_included_in_forbidden_exec_approval_requirement() {
let policy_src = r#"
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(
r#"
prefix_rule(
pattern=["rm"],
decision="forbidden",
justification="destructive command",
)
"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &[
"#
.to_string(),
),
command: vec![
"rm".to_string(),
"-rf".to_string(),
"/some/important/folder".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Forbidden {
reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string()
}
);
reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string(),
},
)
.await;
}
#[tokio::test]
async fn exec_approval_requirement_prefers_execpolicy_match() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let command = vec!["rm".to_string()];
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::NeedsApproval {
reason: Some("`rm` requires approval by policy".to_string()),
proposed_execpolicy_amendment: None,
}
);
},
)
.await;
}
#[tokio::test]
@@ -727,31 +686,22 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
prefix_rule(pattern=["git"], decision="allow")
"#
);
let mut parser = PolicyParser::new();
parser
.parse("test.rules", &policy_src)
.expect("parse policy");
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
let command = vec![git_path, "status".to_string()];
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(policy_src),
command: vec![git_path, "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
}
);
},
)
.await;
}
#[tokio::test]
@@ -770,93 +720,71 @@ host_executable(name = "git", paths = ["{allowed_git_path_literal}"])
prefix_rule(pattern=["git"], decision="prompt")
"#
);
let mut parser = PolicyParser::new();
parser
.parse("test.rules", &policy_src)
.expect("parse policy");
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
let command = vec![disallowed_git_path, "status".to_string()];
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(policy_src),
command: vec![disallowed_git_path.clone(), "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
}
);
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
disallowed_git_path,
"status".to_string(),
])),
},
)
.await;
}
#[tokio::test]
async fn requested_prefix_rule_can_approve_absolute_path_commands() {
let command = vec![
host_program_path("cargo"),
"install".to_string(),
"cargo-insta".to_string(),
];
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
host_program_path("cargo"),
"install".to_string(),
"cargo-insta".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"cargo".to_string(),
"install".to_string(),
])),
}
);
},
)
.await;
}
#[tokio::test]
async fn exec_approval_requirement_respects_approval_policy() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let command = vec!["rm".to_string()];
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
command: vec!["rm".to_string()],
approval_policy: AskForApproval::Never,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Forbidden {
reason: PROMPT_CONFLICT_REASON.to_string()
}
);
reason: PROMPT_CONFLICT_REASON.to_string(),
},
)
.await;
}
#[test]
@@ -903,11 +831,10 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
#[tokio::test]
async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled()
{
let command = vec!["madeup-cmd".to_string()];
let requirement = ExecPolicyManager::default()
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec!["madeup-cmd".to_string()],
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
@@ -915,19 +842,16 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
request_permissions: true,
mcp_elicitations: true,
}),
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Forbidden {
reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(),
}
);
},
)
.await;
}
#[tokio::test]
@@ -1239,178 +1163,140 @@ async fn append_execpolicy_amendment_rejects_empty_prefix() {
async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() {
let command = vec!["cargo".to_string(), "build".to_string()];
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: command.clone(),
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command))
}
);
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
},
)
.await;
}
#[tokio::test]
async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let command = vec!["rm".to_string()];
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::NeedsApproval {
reason: Some("`rm` requires approval by policy".to_string()),
proposed_execpolicy_amendment: None,
}
);
},
)
.await;
}
#[tokio::test]
async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"cargo build && echo ok".to_string(),
];
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
"bash".to_string(),
"-lc".to_string(),
"cargo build && echo ok".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"cargo".to_string(),
"build".to_string()
"build".to_string(),
])),
}
);
},
)
.await;
}
#[tokio::test]
async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() {
let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"cat && apple".to_string(),
];
assert_eq!(
ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(policy_src.to_string()),
command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"apple".to_string()
"apple".to_string(),
])),
}
);
},
)
.await;
}
#[tokio::test]
async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
let command = vec!["echo".to_string(), "safe".to_string()];
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
}
);
},
)
.await;
}
#[tokio::test]
async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() {
let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let command = vec!["echo".to_string(), "safe".to_string()];
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
command: vec!["echo".to_string(), "safe".to_string()],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
}
);
},
)
.await;
}
fn derive_requested_execpolicy_amendment_for_test(
@@ -1560,25 +1446,23 @@ fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() {
#[tokio::test]
async fn dangerous_rm_rf_requires_approval_in_danger_full_access() {
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
}
);
},
)
.await;
}
fn vec_str(items: &[&str]) -> Vec<String> {
@@ -1686,3 +1570,100 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
(unless AskForApproval::Never is specified)."#
);
}
#[tokio::test]
async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
command: vec_str(&["rm", "-rf", "/tmp/nonexistent"]),
}),
},
)
.await;
}
#[tokio::test]
async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()),
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Forbidden {
reason: "approval required by policy, but AskForApproval is set to Never".to_string(),
},
)
.await;
}
struct ExecApprovalRequirementScenario {
/// Source for the Starlark `.rules` file.
policy_src: Option<String>,
command: Vec<String>,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
sandbox_permissions: SandboxPermissions,
prefix_rule: Option<Vec<String>>,
}
async fn assert_exec_approval_requirement_for_command(
test: ExecApprovalRequirementScenario,
expected_requirement: ExecApprovalRequirement,
) {
let ExecApprovalRequirementScenario {
policy_src,
command,
approval_policy,
sandbox_policy,
file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
} = test;
let policy = match policy_src {
Some(src) => {
let mut parser = PolicyParser::new();
parser
.parse("test.rules", src.as_str())
.expect("parse policy");
Arc::new(parser.build())
}
None => Arc::new(Policy::empty()),
};
let requirement = ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy,
sandbox_policy: &sandbox_policy,
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
})
.await;
assert_eq!(requirement, expected_requirement);
}
+40 -11
View File
@@ -457,6 +457,9 @@ const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title";
const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description";
const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params";
const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display";
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default";
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow";
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS: &str = "mcp_tool_call__full_access";
pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool {
question_id
@@ -494,14 +497,22 @@ async fn maybe_request_mcp_tool_approval(
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let approval_required = annotations.is_some_and(requires_mcp_tool_approval);
let mut monitor_reason = None;
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve
|| (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context));
if approval_mode == AppToolApproval::Approve {
if auto_approved_by_policy {
if !approval_required {
return None;
}
match maybe_monitor_auto_approved_mcp_tool_call(sess, turn_context, invocation, metadata)
.await
match maybe_monitor_auto_approved_mcp_tool_call(
sess,
turn_context,
invocation,
metadata,
approval_mode,
)
.await
{
ArcMonitorOutcome::Ok => return None,
ArcMonitorOutcome::AskUser(reason) => {
@@ -515,13 +526,8 @@ async fn maybe_request_mcp_tool_approval(
}
}
if approval_mode == AppToolApproval::Auto {
if is_full_access_mode(turn_context) {
return None;
}
if !approval_required {
return None;
}
if approval_mode == AppToolApproval::Auto && !approval_required {
return None;
}
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
@@ -653,9 +659,16 @@ async fn maybe_monitor_auto_approved_mcp_tool_call(
turn_context: &TurnContext,
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> ArcMonitorOutcome {
let action = prepare_arc_request_action(invocation, metadata);
monitor_action(sess, turn_context, action).await
monitor_action(
sess,
turn_context,
action,
mcp_tool_approval_callsite_mode(approval_mode, turn_context),
)
.await
}
fn prepare_arc_request_action(
@@ -749,6 +762,22 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool {
)
}
fn mcp_tool_approval_callsite_mode(
approval_mode: AppToolApproval,
turn_context: &TurnContext,
) -> &'static str {
match approval_mode {
AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW,
AppToolApproval::Auto | AppToolApproval::Prompt => {
if approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context) {
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS
} else {
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT
}
}
}
}
pub(crate) async fn lookup_mcp_tool_metadata(
sess: &Session,
turn_context: &TurnContext,
+109
View File
@@ -776,6 +776,38 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
);
}
#[tokio::test]
async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_access() {
let (_session, mut turn_context) = make_session_and_context().await;
assert_eq!(
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
"mcp_tool_call__default"
);
assert_eq!(
mcp_tool_approval_callsite_mode(AppToolApproval::Prompt, &turn_context),
"mcp_tool_call__default"
);
assert_eq!(
mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context),
"mcp_tool_call__always_allow"
);
turn_context
.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn_context
.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
assert_eq!(
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
"mcp_tool_call__full_access"
);
}
#[test]
fn declined_elicitation_response_stays_decline() {
let response = parse_mcp_tool_approval_elicitation_response(
@@ -1035,6 +1067,83 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
);
}
#[tokio::test]
async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "dangerous_tool",
"why": "high-risk action",
}],
})))
.expect(1)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
turn_context
.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn_context
.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "dangerous_tool".to_string(),
arguments: Some(serde_json::json!({ "id": 1 })),
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
codex_apps_meta: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-2",
&invocation,
Some(&metadata),
AppToolApproval::Auto,
)
.await;
assert_eq!(
decision,
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
))
);
}
#[tokio::test]
async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_enabled() {
use wiremock::Mock;
@@ -38,6 +38,7 @@ async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> {
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
-356
View File
@@ -1,356 +0,0 @@
use crate::default_client::build_reqwest_client;
use reqwest::Client;
use serde::Deserialize;
use std::fs;
use std::io::Cursor;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use zip::ZipArchive;
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json";
const GITHUB_API_VERSION_HEADER: &str = "2022-11-28";
const OPENAI_PLUGINS_OWNER: &str = "openai";
const OPENAI_PLUGINS_REPO: &str = "plugins";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Deserialize)]
struct GitHubRepositorySummary {
default_branch: String,
}
#[derive(Debug, Deserialize)]
struct GitHubGitRefSummary {
object: GitHubGitRefObject,
}
#[derive(Debug, Deserialize)]
struct GitHubGitRefObject {
sha: String,
}
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
}
pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path())
}
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<String, String> {
sync_openai_plugins_repo_with_api_base_url(codex_home, GITHUB_API_BASE_URL)
}
fn sync_openai_plugins_repo_with_api_base_url(
codex_home: &Path,
api_base_url: &str,
) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?;
let local_sha = read_sha_file(&sha_path);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() {
return Ok(remote_sha);
}
let Some(parent) = repo_path.parent() else {
return Err(format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
));
};
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins parent directory {}: {err}",
parent.display()
)
})?;
let clone_dir = tempfile::Builder::new()
.prefix("plugins-clone-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create temporary curated plugins directory in {}: {err}",
parent.display()
)
})?;
let cloned_repo_path = clone_dir.path().join("repo");
let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?;
extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?;
if !cloned_repo_path
.join(".agents/plugins/marketplace.json")
.is_file()
{
return Err(format!(
"curated plugins archive missing marketplace manifest at {}",
cloned_repo_path
.join(".agents/plugins/marketplace.json")
.display()
));
}
if repo_path.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("plugins-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create curated plugins backup directory in {}: {err}",
parent.display()
)
})?;
let backup_repo_path = backup_dir.path().join("repo");
fs::rename(&repo_path, &backup_repo_path).map_err(|err| {
format!(
"failed to move previous curated plugins repo out of the way at {}: {err}",
repo_path.display()
)
})?;
if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) {
let rollback_result = fs::rename(&backup_repo_path, &repo_path);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate new curated plugins repo at {}: {err}",
repo_path.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("repo");
Err(format!(
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
repo_path.display(),
backup_path.display()
))
}
};
}
} else {
fs::rename(&cloned_repo_path, &repo_path).map_err(|err| {
format!(
"failed to activate curated plugins repo at {}: {err}",
repo_path.display()
)
})?;
}
if let Some(parent) = sha_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins sha directory {}: {err}",
parent.display()
)
})?;
}
fs::write(&sha_path, format!("{remote_sha}\n")).map_err(|err| {
format!(
"failed to write curated plugins sha file {}: {err}",
sha_path.display()
)
})?;
Ok(remote_sha)
}
async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result<String, String> {
let api_base_url = api_base_url.trim_end_matches('/');
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
let client = build_reqwest_client();
let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?;
let repo_summary: GitHubRepositorySummary =
serde_json::from_str(&repo_body).map_err(|err| {
format!("failed to parse curated plugins repository response from {repo_url}: {err}")
})?;
if repo_summary.default_branch.is_empty() {
return Err(format!(
"curated plugins repository response from {repo_url} did not include a default branch"
));
}
let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch);
let git_ref_body =
fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?;
let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| {
format!("failed to parse curated plugins ref response from {git_ref_url}: {err}")
})?;
if git_ref.object.sha.is_empty() {
return Err(format!(
"curated plugins ref response from {git_ref_url} did not include a HEAD sha"
));
}
Ok(git_ref.object.sha)
}
async fn fetch_curated_repo_zipball(
api_base_url: &str,
remote_sha: &str,
) -> Result<Vec<u8>, String> {
let api_base_url = api_base_url.trim_end_matches('/');
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
let zipball_url = format!("{repo_url}/zipball/{remote_sha}");
let client = build_reqwest_client();
fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await
}
async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
let response = github_request(client, url)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!(
"{context} from {url} failed with status {status}: {body}"
));
}
Ok(body)
}
async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
let response = github_request(client, url)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
if !status.is_success() {
let body_text = String::from_utf8_lossy(&body);
return Err(format!(
"{context} from {url} failed with status {status}: {body_text}"
));
}
Ok(body.to_vec())
}
fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder {
client
.get(url)
.timeout(CURATED_PLUGINS_HTTP_TIMEOUT)
.header("accept", GITHUB_API_ACCEPT_HEADER)
.header("x-github-api-version", GITHUB_API_VERSION_HEADER)
}
fn read_sha_file(sha_path: &Path) -> Option<String> {
fs::read_to_string(sha_path)
.ok()
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
}
fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> {
fs::create_dir_all(destination).map_err(|err| {
format!(
"failed to create curated plugins extraction directory {}: {err}",
destination.display()
)
})?;
let cursor = Cursor::new(bytes);
let mut archive = ZipArchive::new(cursor)
.map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?;
let Some(relative_path) = entry.enclosed_name() else {
return Err(format!(
"curated plugins zip entry `{}` escapes extraction root",
entry.name()
));
};
let mut components = relative_path.components();
let Some(Component::Normal(_)) = components.next() else {
continue;
};
let output_relative = components.fold(PathBuf::new(), |mut path, component| {
if let Component::Normal(segment) = component {
path.push(segment);
}
path
});
if output_relative.as_os_str().is_empty() {
continue;
}
let output_path = destination.join(&output_relative);
if entry.is_dir() {
fs::create_dir_all(&output_path).map_err(|err| {
format!(
"failed to create curated plugins directory {}: {err}",
output_path.display()
)
})?;
continue;
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins directory {}: {err}",
parent.display()
)
})?;
}
let mut output = fs::File::create(&output_path).map_err(|err| {
format!(
"failed to create curated plugins file {}: {err}",
output_path.display()
)
})?;
std::io::copy(&mut entry, &mut output).map_err(|err| {
format!(
"failed to write curated plugins file {}: {err}",
output_path.display()
)
})?;
apply_zip_permissions(&entry, &output_path)?;
}
Ok(())
}
#[cfg(unix)]
fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> {
let Some(mode) = entry.unix_mode() else {
return Ok(());
};
fs::set_permissions(output_path, fs::Permissions::from_mode(mode)).map_err(|err| {
format!(
"failed to set permissions on curated plugins file {}: {err}",
output_path.display()
)
})
}
#[cfg(not(unix))]
fn apply_zip_permissions(
_entry: &zip::read::ZipFile<'_>,
_output_path: &Path,
) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
#[path = "curated_repo_tests.rs"]
mod tests;
@@ -1,159 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
use std::io::Write;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
assert_eq!(
curated_plugins_repo_path(tmp.path()),
tmp.path().join(".tmp/plugins")
);
}
#[test]
fn read_curated_plugins_sha_reads_trimmed_sha_file() {
let tmp = tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha");
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some("abc123")
);
}
#[tokio::test]
async fn sync_openai_plugins_repo_downloads_zipball_and_records_sha() {
let tmp = tempdir().expect("tempdir");
let server = MockServer::start().await;
let sha = "0123456789abcdef0123456789abcdef01234567";
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(curated_repo_zipball_bytes(sha)),
)
.mount(&server)
.await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri)
})
.await
.expect("sync task should join")
.expect("sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
assert!(
repo_path
.join("plugins/gmail/.codex-plugin/plugin.json")
.is_file()
);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[tokio::test]
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
let tmp = tempdir().expect("tempdir");
let repo_path = curated_plugins_repo_path(tmp.path());
fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo");
fs::write(
repo_path.join(".agents/plugins/marketplace.json"),
r#"{"name":"openai-curated","plugins":[]}"#,
)
.expect("write marketplace");
fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
let sha = "fedcba9876543210fedcba9876543210fedcba98";
fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri)
})
.await
.expect("sync task should join")
.expect("sync should succeed");
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
}
fn curated_repo_zipball_bytes(sha: &str) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut writer = ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
let root = format!("openai-plugins-{sha}");
writer
.start_file(format!("{root}/.agents/plugins/marketplace.json"), options)
.expect("start marketplace entry");
writer
.write_all(
br#"{
"name": "openai-curated",
"plugins": [
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail"
}
}
]
}"#,
)
.expect("write marketplace");
writer
.start_file(
format!("{root}/plugins/gmail/.codex-plugin/plugin.json"),
options,
)
.expect("start plugin manifest entry");
writer
.write_all(br#"{"name":"gmail"}"#)
.expect("write plugin manifest");
writer.finish().expect("finish zip writer").into_inner()
}
+7 -1
View File
@@ -60,6 +60,7 @@ use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Instant;
use tokio::sync::Mutex;
use toml_edit::value;
use tracing::info;
use tracing::warn;
@@ -463,6 +464,7 @@ pub struct PluginsManager {
store: PluginStore,
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
remote_sync_lock: Mutex<()>,
restriction_product: Option<Product>,
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
}
@@ -488,6 +490,7 @@ impl PluginsManager {
store: PluginStore::new(codex_home),
featured_plugin_ids_cache: RwLock::new(None),
cached_enabled_outcome: RwLock::new(None),
remote_sync_lock: Mutex::new(()),
restriction_product,
analytics_events_client: RwLock::new(None),
}
@@ -619,7 +622,8 @@ impl PluginsManager {
if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) {
return Ok(featured_plugin_ids);
}
let featured_plugin_ids = fetch_remote_featured_plugin_ids(config, auth).await?;
let featured_plugin_ids =
fetch_remote_featured_plugin_ids(config, auth, self.restriction_product).await?;
self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids);
Ok(featured_plugin_ids)
}
@@ -777,6 +781,8 @@ impl PluginsManager {
auth: Option<&CodexAuth>,
additive_only: bool,
) -> Result<RemotePluginSyncResult, PluginRemoteSyncError> {
let _remote_sync_guard = self.remote_sync_lock.lock().await;
if !config.features.enabled(Feature::Plugins) {
return Ok(RemotePluginSyncResult::default());
}
@@ -23,6 +23,7 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
@@ -1899,6 +1900,74 @@ plugins = true
);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "chat"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new_with_restriction_product(
tmp.path().to_path_buf(),
Some(Product::Chatgpt),
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new_with_restriction_product(tmp.path().to_path_buf(), None);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(&config, None)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]);
}
#[test]
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() {
let tmp = tempfile::tempdir().unwrap();
+3 -4
View File
@@ -1,4 +1,3 @@
mod curated_repo;
mod discoverable;
mod injection;
mod manager;
@@ -12,9 +11,6 @@ mod store;
pub(crate) mod test_support;
mod toggles;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::read_curated_plugins_sha;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
@@ -52,5 +48,8 @@ pub use remote::RemotePluginFetchError;
pub use remote::fetch_remote_featured_plugin_ids;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub(crate) use startup_sync::curated_plugins_repo_path;
pub(crate) use startup_sync::read_curated_plugins_sha;
pub(crate) use startup_sync::sync_openai_plugins_repo;
pub use store::PluginId;
pub use toggles::collect_plugin_enabled_candidates;
+6
View File
@@ -1,6 +1,7 @@
use crate::auth::CodexAuth;
use crate::config::Config;
use crate::default_client::build_reqwest_client;
use codex_protocol::protocol::Product;
use serde::Deserialize;
use std::time::Duration;
use url::Url;
@@ -162,12 +163,17 @@ pub(crate) async fn fetch_remote_plugin_status(
pub async fn fetch_remote_featured_plugin_ids(
config: &Config,
auth: Option<&CodexAuth>,
product: Option<Product>,
) -> Result<Vec<String>, RemotePluginFetchError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/featured");
let client = build_reqwest_client();
let mut request = client
.get(&url)
.query(&[(
"platform",
product.unwrap_or(Product::Codex).to_app_platform(),
)])
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
+547 -78
View File
@@ -1,19 +1,143 @@
use crate::default_client::build_reqwest_client;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use reqwest::Client;
use serde::Deserialize;
use tracing::info;
use tracing::warn;
use zip::ZipArchive;
use crate::AuthManager;
use crate::config::Config;
use super::PluginsManager;
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json";
const GITHUB_API_VERSION_HEADER: &str = "2022-11-28";
const OPENAI_PLUGINS_OWNER: &str = "openai";
const OPENAI_PLUGINS_REPO: &str = "plugins";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Deserialize)]
struct GitHubRepositorySummary {
default_branch: String,
}
#[derive(Debug, Deserialize)]
struct GitHubGitRefSummary {
object: GitHubGitRefObject,
}
#[derive(Debug, Deserialize)]
struct GitHubGitRefObject {
sha: String,
}
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
}
pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path())
}
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<String, String> {
sync_openai_plugins_repo_with_transport_overrides(codex_home, "git", GITHUB_API_BASE_URL)
}
fn sync_openai_plugins_repo_with_transport_overrides(
codex_home: &Path,
git_binary: &str,
api_base_url: &str,
) -> Result<String, String> {
match sync_openai_plugins_repo_via_git(codex_home, git_binary) {
Ok(remote_sha) => Ok(remote_sha),
Err(err) => {
warn!(
error = %err,
git_binary,
"git sync failed for curated plugin sync; falling back to GitHub HTTP"
);
sync_openai_plugins_repo_via_http(codex_home, api_base_url)
}
}
}
fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let remote_sha = git_ls_remote_head_sha(git_binary)?;
let local_sha = read_local_git_or_sha_file(&repo_path, &sha_path, git_binary);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
return Ok(remote_sha);
}
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let clone_output = run_git_command_with_timeout(
Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg("--depth")
.arg("1")
.arg("https://github.com/openai/plugins.git")
.arg(&cloned_repo_path),
"git clone curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
let cloned_sha = git_head_sha(&cloned_repo_path, git_binary)?;
if cloned_sha != remote_sha {
return Err(format!(
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
));
}
ensure_marketplace_manifest_exists(&cloned_repo_path)?;
activate_curated_repo(&repo_path, &cloned_repo_path)?;
write_curated_plugins_sha(&sha_path, &remote_sha)?;
Ok(remote_sha)
}
fn sync_openai_plugins_repo_via_http(
codex_home: &Path,
api_base_url: &str,
) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?;
let local_sha = read_sha_file(&sha_path);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() {
return Ok(remote_sha);
}
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?;
extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?;
ensure_marketplace_manifest_exists(&cloned_repo_path)?;
activate_curated_repo(&repo_path, &cloned_repo_path)?;
write_curated_plugins_sha(&sha_path, &remote_sha)?;
Ok(remote_sha)
}
pub(super) fn start_startup_remote_plugin_sync_once(
manager: Arc<PluginsManager>,
codex_home: PathBuf,
@@ -103,93 +227,438 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::
tokio::fs::write(marker_path, b"ok\n").await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::CodexAuth;
use crate::config::CONFIG_TOML_FILE;
use crate::plugins::curated_plugins_repo_path;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf, String> {
let Some(parent) = repo_path.parent() else {
return Err(format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
));
};
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins parent directory {}: {err}",
parent.display()
)
})?;
#[tokio::test]
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
let tmp = tempdir().expect("tempdir");
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
let clone_dir = tempfile::Builder::new()
.prefix("plugins-clone-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create temporary curated plugins directory in {}: {err}",
parent.display()
)
})?;
Ok(clone_dir.keep())
}
[plugins."linear@openai-curated"]
enabled = false
"#,
);
fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> {
if repo_path.join(".agents/plugins/marketplace.json").is_file() {
return Ok(());
}
Err(format!(
"curated plugins archive missing marketplace manifest at {}",
repo_path.join(".agents/plugins/marketplace.json").display()
))
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
fn activate_curated_repo(repo_path: &Path, staged_repo_path: &Path) -> Result<(), String> {
if repo_path.exists() {
let parent = repo_path.parent().ok_or_else(|| {
format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
)
})?;
let backup_dir = tempfile::Builder::new()
.prefix("plugins-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create curated plugins backup directory in {}: {err}",
parent.display()
)
})?;
let backup_repo_path = backup_dir.path().join("repo");
let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
std::fs::rename(repo_path, &backup_repo_path).map_err(|err| {
format!(
"failed to move previous curated plugins repo out of the way at {}: {err}",
repo_path.display()
)
})?;
start_startup_remote_plugin_sync_once(
Arc::clone(&manager),
tmp.path().to_path_buf(),
config,
auth_manager,
);
let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if marker_path.is_file() {
break;
if let Err(err) = std::fs::rename(staged_repo_path, repo_path) {
let rollback_result = std::fs::rename(&backup_repo_path, repo_path);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate new curated plugins repo at {}: {err}",
repo_path.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("repo");
Err(format!(
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
repo_path.display(),
backup_path.display()
))
}
tokio::time::sleep(Duration::from_millis(10)).await;
};
}
} else {
std::fs::rename(staged_repo_path, repo_path).map_err(|err| {
format!(
"failed to activate curated plugins repo at {}: {err}",
repo_path.display()
)
})?;
}
Ok(())
}
fn write_curated_plugins_sha(sha_path: &Path, remote_sha: &str) -> Result<(), String> {
if let Some(parent) = sha_path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins sha directory {}: {err}",
parent.display()
)
})?;
}
std::fs::write(sha_path, format!("{remote_sha}\n")).map_err(|err| {
format!(
"failed to write curated plugins sha file {}: {err}",
sha_path.display()
)
})
}
fn read_local_git_or_sha_file(
repo_path: &Path,
sha_path: &Path,
git_binary: &str,
) -> Option<String> {
if repo_path.join(".git").is_dir()
&& let Ok(sha) = git_head_sha(repo_path, git_binary)
{
return Some(sha);
}
read_sha_file(sha_path)
}
fn git_ls_remote_head_sha(git_binary: &str) -> Result<String, String> {
let output = run_git_command_with_timeout(
Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("ls-remote")
.arg("https://github.com/openai/plugins.git")
.arg("HEAD"),
"git ls-remote curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(first_line) = stdout.lines().next() else {
return Err("git ls-remote returned empty output for curated plugins repo".to_string());
};
let Some((sha, _)) = first_line.split_once('\t') else {
return Err(format!(
"unexpected git ls-remote output for curated plugins repo: {first_line}"
));
};
if sha.is_empty() {
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
}
Ok(sha.to_string())
}
fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result<String, String> {
let output = Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(repo_path)
.arg("rev-parse")
.arg("HEAD")
.output()
.map_err(|err| {
format!(
"failed to run git rev-parse HEAD in {}: {err}",
repo_path.display()
)
})?;
ensure_git_success(&output, "git rev-parse HEAD")?;
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
return Err(format!(
"git rev-parse HEAD returned empty output in {}",
repo_path.display()
));
}
Ok(sha)
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
timeout: Duration,
) -> Result<Output, String> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run {context}: {err}"))?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
})
.await
.expect("marker should be written");
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_SHA}"
if start.elapsed() >= timeout {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
let _ = child.kill();
let output = child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return if stderr.is_empty() {
Err(format!("{context} timed out after {}s", timeout.as_secs()))
} else {
Err(format!(
"{context} timed out after {}s: {stderr}",
timeout.as_secs()
))
.is_dir()
);
let config = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))
.expect("config should exist");
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains("enabled = true"));
};
}
let marker_contents =
std::fs::read_to_string(marker_path).expect("marker should be readable");
assert_eq!(marker_contents, "ok\n");
std::thread::sleep(Duration::from_millis(100));
}
}
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!("{context} failed with status {}", output.status))
} else {
Err(format!(
"{context} failed with status {}: {stderr}",
output.status
))
}
}
async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result<String, String> {
let api_base_url = api_base_url.trim_end_matches('/');
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
let client = build_reqwest_client();
let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?;
let repo_summary: GitHubRepositorySummary =
serde_json::from_str(&repo_body).map_err(|err| {
format!("failed to parse curated plugins repository response from {repo_url}: {err}")
})?;
if repo_summary.default_branch.is_empty() {
return Err(format!(
"curated plugins repository response from {repo_url} did not include a default branch"
));
}
let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch);
let git_ref_body =
fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?;
let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| {
format!("failed to parse curated plugins ref response from {git_ref_url}: {err}")
})?;
if git_ref.object.sha.is_empty() {
return Err(format!(
"curated plugins ref response from {git_ref_url} did not include a HEAD sha"
));
}
Ok(git_ref.object.sha)
}
async fn fetch_curated_repo_zipball(
api_base_url: &str,
remote_sha: &str,
) -> Result<Vec<u8>, String> {
let api_base_url = api_base_url.trim_end_matches('/');
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
let zipball_url = format!("{repo_url}/zipball/{remote_sha}");
let client = build_reqwest_client();
fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await
}
async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
let response = github_request(client, url)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!(
"{context} from {url} failed with status {status}: {body}"
));
}
Ok(body)
}
async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
let response = github_request(client, url)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
if !status.is_success() {
let body_text = String::from_utf8_lossy(&body);
return Err(format!(
"{context} from {url} failed with status {status}: {body_text}"
));
}
Ok(body.to_vec())
}
fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder {
client
.get(url)
.timeout(CURATED_PLUGINS_HTTP_TIMEOUT)
.header("accept", GITHUB_API_ACCEPT_HEADER)
.header("x-github-api-version", GITHUB_API_VERSION_HEADER)
}
fn read_sha_file(sha_path: &Path) -> Option<String> {
std::fs::read_to_string(sha_path)
.ok()
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
}
fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> {
std::fs::create_dir_all(destination).map_err(|err| {
format!(
"failed to create curated plugins extraction directory {}: {err}",
destination.display()
)
})?;
let cursor = std::io::Cursor::new(bytes);
let mut archive = ZipArchive::new(cursor)
.map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?;
let Some(relative_path) = entry.enclosed_name() else {
return Err(format!(
"curated plugins zip entry `{}` escapes extraction root",
entry.name()
));
};
let mut components = relative_path.components();
let Some(std::path::Component::Normal(_)) = components.next() else {
continue;
};
let output_relative = components.fold(PathBuf::new(), |mut path, component| {
if let std::path::Component::Normal(segment) = component {
path.push(segment);
}
path
});
if output_relative.as_os_str().is_empty() {
continue;
}
let output_path = destination.join(&output_relative);
if entry.is_dir() {
std::fs::create_dir_all(&output_path).map_err(|err| {
format!(
"failed to create curated plugins directory {}: {err}",
output_path.display()
)
})?;
continue;
}
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins directory {}: {err}",
parent.display()
)
})?;
}
let mut output = std::fs::File::create(&output_path).map_err(|err| {
format!(
"failed to create curated plugins file {}: {err}",
output_path.display()
)
})?;
std::io::copy(&mut entry, &mut output).map_err(|err| {
format!(
"failed to write curated plugins file {}: {err}",
output_path.display()
)
})?;
apply_zip_permissions(&entry, &output_path)?;
}
Ok(())
}
#[cfg(unix)]
fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let Some(mode) = entry.unix_mode() else {
return Ok(());
};
std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|err| {
format!(
"failed to set permissions on curated plugins file {}: {err}",
output_path.display()
)
})
}
#[cfg(not(unix))]
fn apply_zip_permissions(
_entry: &zip::read::ZipFile<'_>,
_output_path: &Path,
) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
#[path = "startup_sync_tests.rs"]
mod tests;
@@ -0,0 +1,383 @@
use super::*;
use crate::auth::CodexAuth;
use crate::config::CONFIG_TOML_FILE;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use pretty_assertions::assert_eq;
use std::io::Write;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
assert_eq!(
curated_plugins_repo_path(tmp.path()),
tmp.path().join(".tmp/plugins")
);
}
#[test]
fn read_curated_plugins_sha_reads_trimmed_sha_file() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
std::fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha");
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some("abc123")
);
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_prefers_git_when_available() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-")
.tempdir()
.expect("tempdir");
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
std::fs::write(
&git_path,
format!(
r#"#!/bin/sh
if [ "$1" = "ls-remote" ]; then
printf '%s\tHEAD\n' "{sha}"
exit 0
fi
if [ "$1" = "clone" ]; then
dest="$5"
mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin"
cat > "$dest/.agents/plugins/marketplace.json" <<'EOF'
{{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}}
EOF
printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json"
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
printf '%s\n' "{sha}"
exit 0
fi
echo "unexpected git invocation: $@" >&2
exit 1
"#
),
)
.expect("write fake git");
let mut permissions = std::fs::metadata(&git_path)
.expect("metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&git_path, permissions).expect("chmod");
let synced_sha = sync_openai_plugins_repo_with_transport_overrides(
tmp.path(),
git_path.to_str().expect("utf8 path"),
"http://127.0.0.1:9",
)
.expect("git sync should succeed");
assert_eq!(synced_sha, sha);
assert!(curated_plugins_repo_path(tmp.path()).join(".git").is_dir());
assert!(
curated_plugins_repo_path(tmp.path())
.join(".agents/plugins/marketplace.json")
.is_file()
);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() {
let tmp = tempdir().expect("tempdir");
let server = MockServer::start().await;
let sha = "0123456789abcdef0123456789abcdef01234567";
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(curated_repo_zipball_bytes(sha)),
)
.mount(&server)
.await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
let synced_sha = tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
tmp_path.as_path(),
"missing-git-for-test",
&server_uri,
)
})
.await
.expect("sync task should join")
.expect("fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, sha);
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
assert!(
repo_path
.join("plugins/gmail/.codex-plugin/plugin.json")
.is_file()
);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-fail-")
.tempdir()
.expect("tempdir");
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
std::fs::write(
&git_path,
r#"#!/bin/sh
echo "simulated git failure" >&2
exit 1
"#,
)
.expect("write fake git");
let mut permissions = std::fs::metadata(&git_path)
.expect("metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&git_path, permissions).expect("chmod");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(curated_repo_zipball_bytes(sha)),
)
.mount(&server)
.await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
let synced_sha = tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
tmp_path.as_path(),
git_path.to_str().expect("utf8 path"),
&server_uri,
)
})
.await
.expect("sync task should join")
.expect("fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, sha);
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
assert!(
repo_path
.join("plugins/gmail/.codex-plugin/plugin.json")
.is_file()
);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[tokio::test]
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
let tmp = tempdir().expect("tempdir");
let repo_path = curated_plugins_repo_path(tmp.path());
std::fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo");
std::fs::write(
repo_path.join(".agents/plugins/marketplace.json"),
r#"{"name":"openai-curated","plugins":[]}"#,
)
.expect("write marketplace");
std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
let sha = "fedcba9876543210fedcba9876543210fedcba98";
std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
tmp_path.as_path(),
"missing-git-for-test",
&server_uri,
)
})
.await
.expect("sync task should join")
.expect("sync should succeed");
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
}
#[tokio::test]
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
let tmp = tempdir().expect("tempdir");
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
start_startup_remote_plugin_sync_once(
Arc::clone(&manager),
tmp.path().to_path_buf(),
config,
auth_manager,
);
let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if marker_path.is_file() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("marker should be written");
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_SHA}"
))
.is_dir()
);
let config =
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist");
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains("enabled = true"));
let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable");
assert_eq!(marker_contents, "ok\n");
}
fn curated_repo_zipball_bytes(sha: &str) -> Vec<u8> {
let cursor = std::io::Cursor::new(Vec::new());
let mut writer = ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
let root = format!("openai-plugins-{sha}");
writer
.start_file(format!("{root}/.agents/plugins/marketplace.json"), options)
.expect("start marketplace entry");
writer
.write_all(
br#"{
"name": "openai-curated",
"plugins": [
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail"
}
}
]
}"#,
)
.expect("write marketplace");
writer
.start_file(
format!("{root}/plugins/gmail/.codex-plugin/plugin.json"),
options,
)
.expect("start plugin manifest entry");
writer
.write_all(br#"{"name":"gmail"}"#)
.expect("write plugin manifest");
writer.finish().expect("finish zip writer").into_inner()
}
@@ -23,6 +23,7 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
.single()
.expect("valid timestamp"),
source: "cli".to_string(),
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: "test-provider".to_string(),
+1
View File
@@ -49,6 +49,7 @@ pub(crate) fn builder_from_session_meta(
builder.model_provider = session_meta.meta.model_provider.clone();
builder.agent_nickname = session_meta.meta.agent_nickname.clone();
builder.agent_role = session_meta.meta.agent_role.clone();
builder.agent_path = session_meta.meta.agent_path.clone();
builder.cwd = session_meta.meta.cwd.clone();
builder.cli_version = Some(session_meta.meta.cli_version.clone());
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
@@ -38,6 +38,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() {
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: Some("openai".to_string()),
@@ -88,6 +89,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() {
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: Some("openai".to_string()),
@@ -355,6 +357,7 @@ fn write_rollout_in_sessions_with_cwd(
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: Some("test-provider".to_string()),
+1
View File
@@ -405,6 +405,7 @@ impl RolloutRecorder {
cli_version: env!("CARGO_PKG_VERSION").to_string(),
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
agent_path: source.get_agent_path().map(Into::into),
source,
model_provider: Some(config.model_provider_id.clone()),
base_instructions: Some(base_instructions),
+1
View File
@@ -1101,6 +1101,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: Some("test-provider".into()),
+12 -5
View File
@@ -4,18 +4,25 @@ use codex_protocol::protocol::AgentStatus;
/// messages but are not user intent.
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_FRAGMENT;
pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &AgentStatus) -> String {
// TODO(jif) unify with structured schema
pub(crate) fn format_subagent_notification_message(
agent_reference: &str,
status: &AgentStatus,
) -> String {
let payload_json = serde_json::json!({
"agent_id": agent_id,
"agent_path": agent_reference,
"status": status,
})
.to_string();
SUBAGENT_NOTIFICATION_FRAGMENT.wrap(payload_json)
}
pub(crate) fn format_subagent_context_line(agent_id: &str, agent_nickname: Option<&str>) -> String {
pub(crate) fn format_subagent_context_line(
agent_reference: &str,
agent_nickname: Option<&str>,
) -> String {
match agent_nickname.filter(|nickname| !nickname.is_empty()) {
Some(agent_nickname) => format!("- {agent_id}: {agent_nickname}"),
None => format!("- {agent_id}"),
Some(agent_nickname) => format!("- {agent_reference}: {agent_nickname}"),
None => format!("- {agent_reference}"),
}
}
@@ -0,0 +1,17 @@
---
source: core/src/codex_tests.rs
assertion_line: 1282
expression: snapshot
---
Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.
## First Forked Turn Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:fork seed
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
06:message/user:after fork
+30
View File
@@ -153,7 +153,15 @@ impl Session {
) {
self.abort_all_tasks(TurnAbortReason::Replaced).await;
self.clear_connector_selection().await;
self.start_task(turn_context, input, task).await;
}
async fn start_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
task: T,
) {
let task: Arc<dyn SessionTask> = Arc::new(task);
let task_kind = task.kind();
let span_name = task.span_name();
@@ -224,6 +232,22 @@ impl Session {
.await;
}
pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc<Self>) {
if !self.has_queued_response_items_for_next_turn().await {
return;
}
if self.active_turn.lock().await.is_some() {
return;
}
let turn_context = self.new_default_turn().await;
self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
self.start_task(turn_context, Vec::new(), RegularTask::new())
.await;
}
pub async fn abort_all_tasks(self: &Arc<Self>, reason: TurnAbortReason) {
if let Some(mut active_turn) = self.take_active_turn().await {
for task in active_turn.drain_tasks() {
@@ -233,6 +257,9 @@ impl Session {
// in-flight approval wait can surface as a model-visible rejection before TurnAborted.
active_turn.clear_pending().await;
}
if reason == TurnAbortReason::Interrupted {
self.ensure_task_for_queued_response_items().await;
}
}
pub async fn on_task_finished(
@@ -371,6 +398,9 @@ impl Session {
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);
+27
View File
@@ -3,6 +3,8 @@ use crate::CodexAuth;
use crate::ModelProviderInfo;
use crate::OPENAI_PROVIDER_ID;
use crate::agent::AgentControl;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
use crate::codex::Codex;
use crate::codex::CodexSpawnArgs;
use crate::codex::CodexSpawnOk;
@@ -26,6 +28,8 @@ use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillsManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationModeMask;
#[cfg(test)]
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpServerRefreshConfig;
@@ -606,6 +610,29 @@ impl ThreadManagerState {
thread.submit(op).await
}
#[cfg(test)]
/// Append a prebuilt message to a thread by ID outside the normal user-input path.
pub(crate) async fn append_message(
&self,
thread_id: ThreadId,
message: ResponseItem,
) -> CodexResult<String> {
let thread = self.get_thread(thread_id).await?;
thread.append_message(message).await
}
pub(crate) async fn deliver_inter_agent_instruction(
&self,
thread_id: ThreadId,
instruction: InterAgentInstruction,
delivery: InterAgentDelivery,
) -> CodexResult<String> {
let thread = self.get_thread(thread_id).await?;
thread
.deliver_inter_agent_instruction(instruction, delivery)
.await
}
/// Remove a thread from the manager by ID, returning it when present.
pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
self.threads.write().await.remove(thread_id)
@@ -1,51 +0,0 @@
const __codexContentItems = Array.isArray(globalThis.__codexContentItems)
? globalThis.__codexContentItems
: [];
const __codexRuntime = globalThis.__codexRuntime;
delete globalThis.__codexRuntime;
Object.defineProperty(globalThis, '__codexContentItems', {
value: __codexContentItems,
configurable: true,
enumerable: false,
writable: false,
});
(() => {
if (!__codexRuntime || typeof __codexRuntime !== 'object') {
throw new Error('code mode runtime is unavailable');
}
function defineGlobal(name, value) {
Object.defineProperty(globalThis, name, {
value,
configurable: true,
enumerable: true,
writable: false,
});
}
defineGlobal('ALL_TOOLS', __codexRuntime.ALL_TOOLS);
defineGlobal('exit', __codexRuntime.exit);
defineGlobal('image', __codexRuntime.image);
defineGlobal('load', __codexRuntime.load);
defineGlobal('notify', __codexRuntime.notify);
defineGlobal('store', __codexRuntime.store);
defineGlobal('text', __codexRuntime.text);
defineGlobal('tools', __codexRuntime.tools);
defineGlobal('yield_control', __codexRuntime.yield_control);
defineGlobal(
'console',
Object.freeze({
log() {},
info() {},
warn() {},
error() {},
debug() {},
})
);
})();
__CODE_MODE_USER_CODE_PLACEHOLDER__
@@ -1,19 +0,0 @@
## exec
- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console).
- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running.
- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens.
- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
- Tool methods take either string or object as parameter.
- They return either a structured value or a string based on the description above.
- Global helpers:
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
- `text(value: string | number | boolean | undefined | null)`: Appends a text item and returns it. Non-string values are stringified with `JSON.stringify(...)` when possible.
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.
- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running.
@@ -1,8 +1,5 @@
use async_trait::async_trait;
use serde::Deserialize;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
@@ -10,180 +7,52 @@ use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use super::CODE_MODE_PRAGMA_PREFIX;
use super::CodeModeSessionProgress;
use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
use super::build_enabled_tools;
use super::handle_node_message;
use super::protocol::HostToNodeMessage;
use super::protocol::build_source;
use super::handle_runtime_response;
pub struct CodeModeExecuteHandler;
const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct CodeModeExecPragma {
#[serde(default)]
yield_time_ms: Option<u64>,
#[serde(default)]
max_output_tokens: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
struct CodeModeExecArgs {
code: String,
yield_time_ms: Option<u64>,
max_output_tokens: Option<usize>,
}
impl CodeModeExecuteHandler {
async fn execute(
&self,
session: std::sync::Arc<Session>,
turn: std::sync::Arc<TurnContext>,
session: std::sync::Arc<crate::codex::Session>,
turn: std::sync::Arc<crate::codex::TurnContext>,
call_id: String,
code: String,
) -> Result<FunctionToolOutput, FunctionCallError> {
let args = parse_freeform_args(&code)?;
let args =
codex_code_mode::parse_exec_source(&code).map_err(FunctionCallError::RespondToModel)?;
let exec = ExecContext { session, turn };
let enabled_tools = build_enabled_tools(&exec).await;
let service = &exec.session.services.code_mode_service;
let stored_values = service.stored_values().await;
let source =
build_source(&args.code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?;
let cell_id = service.allocate_cell_id().await;
let request_id = service.allocate_request_id().await;
let process_slot = service
.ensure_started()
.await
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
let stored_values = exec
.session
.services
.code_mode_service
.stored_values()
.await;
let started_at = std::time::Instant::now();
let message = HostToNodeMessage::Start {
request_id: request_id.clone(),
cell_id: cell_id.clone(),
tool_call_id: call_id,
default_yield_time_ms: super::DEFAULT_EXEC_YIELD_TIME_MS,
enabled_tools,
stored_values,
source,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
};
let result = {
let mut process_slot = process_slot;
let Some(process) = process_slot.as_mut() else {
return Err(FunctionCallError::RespondToModel(format!(
"{PUBLIC_TOOL_NAME} runner failed to start"
)));
};
let message = process
.send(&request_id, &message)
.await
.map_err(|err| err.to_string());
let message = match message {
Ok(message) => message,
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
};
handle_node_message(
&exec, cell_id, message, /*poll_max_output_tokens*/ None, started_at,
)
let response = exec
.session
.services
.code_mode_service
.execute(codex_code_mode::ExecuteRequest {
tool_call_id: call_id,
enabled_tools,
source: args.code,
stored_values,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
})
.await
};
match result {
Ok(CodeModeSessionProgress::Finished(output))
| Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output),
Err(error) => Err(FunctionCallError::RespondToModel(error)),
}
.map_err(FunctionCallError::RespondToModel)?;
handle_runtime_response(&exec, response, args.max_output_tokens, started_at)
.await
.map_err(FunctionCallError::RespondToModel)
}
}
fn parse_freeform_args(input: &str) -> Result<CodeModeExecArgs, FunctionCallError> {
if input.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(),
));
}
let mut args = CodeModeExecArgs {
code: input.to_string(),
yield_time_ms: None,
max_output_tokens: None,
};
let mut lines = input.splitn(2, '\n');
let first_line = lines.next().unwrap_or_default();
let rest = lines.next().unwrap_or_default();
let trimmed = first_line.trim_start();
let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else {
return Ok(args);
};
if rest.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"exec pragma must be followed by JavaScript source on subsequent lines".to_string(),
));
}
let directive = pragma.trim();
if directive.is_empty() {
return Err(FunctionCallError::RespondToModel(
"exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
.to_string(),
));
}
let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}"
))
})?;
let object = value.as_object().ok_or_else(|| {
FunctionCallError::RespondToModel(
"exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
.to_string(),
)
})?;
for key in object.keys() {
match key.as_str() {
"yield_time_ms" | "max_output_tokens" => {}
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`"
)));
}
}
}
let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}"
))
})?;
if pragma
.yield_time_ms
.is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER)
{
return Err(FunctionCallError::RespondToModel(
"exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(),
));
}
if pragma.max_output_tokens.is_some_and(|max_output_tokens| {
u64::try_from(max_output_tokens)
.map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER)
.unwrap_or(true)
}) {
return Err(FunctionCallError::RespondToModel(
"exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(),
));
}
args.code = rest.to_string();
args.yield_time_ms = pragma.yield_time_ms;
args.max_output_tokens = pragma.max_output_tokens;
Ok(args)
}
#[async_trait]
impl ToolHandler for CodeModeExecuteHandler {
type Output = FunctionToolOutput;
@@ -216,7 +85,3 @@ impl ToolHandler for CodeModeExecuteHandler {
}
}
}
#[cfg(test)]
#[path = "execute_handler_tests.rs"]
mod execute_handler_tests;
+187 -173
View File
@@ -1,15 +1,18 @@
mod execute_handler;
mod process;
mod protocol;
mod service;
mod response_adapter;
mod wait_handler;
mod worker;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_code_mode::CodeModeTurnHost;
use codex_code_mode::RuntimeResponse;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use serde_json::Value as JsonValue;
use tokio_util::sync::CancellationToken;
use crate::client_common::tools::ToolSpec;
use crate::codex::Session;
@@ -17,9 +20,8 @@ use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::tools::ToolRouter;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::code_mode_description::code_mode_tool_reference;
use crate::tools::code_mode_description::normalize_code_mode_identifier;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolPayload;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolCall;
@@ -29,180 +31,202 @@ use crate::truncate::TruncationPolicy;
use crate::truncate::formatted_truncate_text_content_items_with_policy;
use crate::truncate::truncate_function_output_items_with_policy;
use crate::unified_exec::resolve_max_tokens;
use codex_features::Feature;
const CODE_MODE_RUNNER_SOURCE: &str = include_str!("runner.cjs");
const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js");
const CODE_MODE_DESCRIPTION_TEMPLATE: &str = include_str!("description.md");
const CODE_MODE_WAIT_DESCRIPTION_TEMPLATE: &str = include_str!("wait_description.md");
const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:";
const CODE_MODE_ONLY_PREFACE: &str =
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly";
pub(crate) use execute_handler::CodeModeExecuteHandler;
use response_adapter::into_function_call_output_content_items;
pub(crate) use wait_handler::CodeModeWaitHandler;
pub(crate) const PUBLIC_TOOL_NAME: &str = "exec";
pub(crate) const WAIT_TOOL_NAME: &str = "wait";
pub(crate) fn is_code_mode_nested_tool(tool_name: &str) -> bool {
tool_name != PUBLIC_TOOL_NAME && tool_name != WAIT_TOOL_NAME
}
pub(crate) const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000;
pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000;
pub(crate) const PUBLIC_TOOL_NAME: &str = codex_code_mode::PUBLIC_TOOL_NAME;
pub(crate) const WAIT_TOOL_NAME: &str = codex_code_mode::WAIT_TOOL_NAME;
pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = codex_code_mode::DEFAULT_WAIT_YIELD_TIME_MS;
#[derive(Clone)]
pub(super) struct ExecContext {
pub(crate) struct ExecContext {
pub(super) session: Arc<Session>,
pub(super) turn: Arc<TurnContext>,
}
pub(crate) use execute_handler::CodeModeExecuteHandler;
pub(crate) use service::CodeModeService;
pub(crate) use wait_handler::CodeModeWaitHandler;
enum CodeModeSessionProgress {
Finished(FunctionToolOutput),
Yielded { output: FunctionToolOutput },
pub(crate) struct CodeModeService {
inner: codex_code_mode::CodeModeService,
}
enum CodeModeExecutionStatus {
Completed,
Failed,
Running(String),
Terminated,
}
pub(crate) fn tool_description(enabled_tools: &[(String, String)], code_mode_only: bool) -> String {
let description_template = CODE_MODE_DESCRIPTION_TEMPLATE.trim_end();
if !code_mode_only {
return description_template.to_string();
impl CodeModeService {
pub(crate) fn new(_js_repl_node_path: Option<PathBuf>) -> Self {
Self {
inner: codex_code_mode::CodeModeService::new(),
}
}
let mut sections = vec![
CODE_MODE_ONLY_PREFACE.to_string(),
description_template.to_string(),
];
pub(crate) async fn stored_values(&self) -> std::collections::HashMap<String, JsonValue> {
self.inner.stored_values().await
}
if !enabled_tools.is_empty() {
let nested_tool_reference = enabled_tools
.iter()
.map(|(name, nested_description)| {
let global_name = normalize_code_mode_identifier(name);
format!(
"### `{global_name}` (`{name}`)\n{}",
nested_description.trim()
)
pub(crate) async fn replace_stored_values(
&self,
values: std::collections::HashMap<String, JsonValue>,
) {
self.inner.replace_stored_values(values).await;
}
pub(crate) async fn execute(
&self,
request: codex_code_mode::ExecuteRequest,
) -> Result<RuntimeResponse, String> {
self.inner.execute(request).await
}
pub(crate) async fn wait(
&self,
request: codex_code_mode::WaitRequest,
) -> Result<RuntimeResponse, String> {
self.inner.wait(request).await
}
pub(crate) async fn start_turn_worker(
&self,
session: &Arc<Session>,
turn: &Arc<TurnContext>,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> Option<codex_code_mode::CodeModeTurnWorker> {
if !turn.features.enabled(Feature::CodeMode) {
return None;
}
let exec = ExecContext {
session: Arc::clone(session),
turn: Arc::clone(turn),
};
let tool_runtime =
ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker);
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
Some(self.inner.start_turn_worker(host))
}
}
struct CoreTurnHost {
exec: ExecContext,
tool_runtime: ToolCallRuntime,
}
#[async_trait::async_trait]
impl CodeModeTurnHost for CoreTurnHost {
async fn invoke_tool(
&self,
tool_name: String,
input: Option<JsonValue>,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String> {
call_nested_tool(
self.exec.clone(),
self.tool_runtime.clone(),
tool_name,
input,
cancellation_token,
)
.await
.map_err(|error| error.to_string())
}
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
self.exec
.session
.inject_response_items(vec![ResponseInputItem::CustomToolCallOutput {
call_id,
name: Some(PUBLIC_TOOL_NAME.to_string()),
output: FunctionCallOutputPayload::from_text(text),
}])
.await
.map_err(|_| {
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
})
.collect::<Vec<_>>()
.join("\n\n");
sections.push(nested_tool_reference);
}
sections.join("\n\n")
}
pub(crate) fn wait_tool_description() -> &'static str {
CODE_MODE_WAIT_DESCRIPTION_TEMPLATE
}
async fn handle_node_message(
pub(super) async fn handle_runtime_response(
exec: &ExecContext,
cell_id: String,
message: protocol::NodeToHostMessage,
poll_max_output_tokens: Option<Option<usize>>,
response: RuntimeResponse,
max_output_tokens: Option<usize>,
started_at: std::time::Instant,
) -> Result<CodeModeSessionProgress, String> {
match message {
protocol::NodeToHostMessage::ToolCall { .. } => Err(protocol::unexpected_tool_call_error()),
protocol::NodeToHostMessage::Notify { .. } => Err(format!(
"unexpected {PUBLIC_TOOL_NAME} notify message in response path"
)),
protocol::NodeToHostMessage::Yielded { content_items, .. } => {
let mut delta_items = output_content_items_from_json_values(content_items)?;
delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten());
prepend_script_status(
&mut delta_items,
CodeModeExecutionStatus::Running(cell_id),
started_at.elapsed(),
);
Ok(CodeModeSessionProgress::Yielded {
output: FunctionToolOutput::from_content(delta_items, Some(true)),
})
) -> Result<FunctionToolOutput, String> {
let script_status = format_script_status(&response);
match response {
RuntimeResponse::Yielded { content_items, .. } => {
let mut content_items = into_function_call_output_content_items(content_items);
content_items = truncate_code_mode_result(content_items, max_output_tokens);
prepend_script_status(&mut content_items, &script_status, started_at.elapsed());
Ok(FunctionToolOutput::from_content(content_items, Some(true)))
}
protocol::NodeToHostMessage::Terminated { content_items, .. } => {
let mut delta_items = output_content_items_from_json_values(content_items)?;
delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten());
prepend_script_status(
&mut delta_items,
CodeModeExecutionStatus::Terminated,
started_at.elapsed(),
);
Ok(CodeModeSessionProgress::Finished(
FunctionToolOutput::from_content(delta_items, Some(true)),
))
RuntimeResponse::Terminated { content_items, .. } => {
let mut content_items = into_function_call_output_content_items(content_items);
content_items = truncate_code_mode_result(content_items, max_output_tokens);
prepend_script_status(&mut content_items, &script_status, started_at.elapsed());
Ok(FunctionToolOutput::from_content(content_items, Some(true)))
}
protocol::NodeToHostMessage::Result {
RuntimeResponse::Result {
content_items,
stored_values,
error_text,
max_output_tokens_per_exec_call,
..
} => {
let mut content_items = into_function_call_output_content_items(content_items);
exec.session
.services
.code_mode_service
.replace_stored_values(stored_values)
.await;
let mut delta_items = output_content_items_from_json_values(content_items)?;
let success = error_text.is_none();
if let Some(error_text) = error_text {
delta_items.push(FunctionCallOutputContentItem::InputText {
content_items.push(FunctionCallOutputContentItem::InputText {
text: format!("Script error:\n{error_text}"),
});
}
let mut delta_items = truncate_code_mode_result(
delta_items,
poll_max_output_tokens.unwrap_or(max_output_tokens_per_exec_call),
);
prepend_script_status(
&mut delta_items,
if success {
CodeModeExecutionStatus::Completed
} else {
CodeModeExecutionStatus::Failed
},
started_at.elapsed(),
);
Ok(CodeModeSessionProgress::Finished(
FunctionToolOutput::from_content(delta_items, Some(success)),
content_items = truncate_code_mode_result(content_items, max_output_tokens);
prepend_script_status(&mut content_items, &script_status, started_at.elapsed());
Ok(FunctionToolOutput::from_content(
content_items,
Some(success),
))
}
}
}
fn format_script_status(response: &RuntimeResponse) -> String {
match response {
RuntimeResponse::Yielded { cell_id, .. } => {
format!("Script running with cell ID {cell_id}")
}
RuntimeResponse::Terminated { .. } => "Script terminated".to_string(),
RuntimeResponse::Result { error_text, .. } => {
if error_text.is_none() {
"Script completed".to_string()
} else {
"Script failed".to_string()
}
}
}
}
fn prepend_script_status(
content_items: &mut Vec<FunctionCallOutputContentItem>,
status: CodeModeExecutionStatus,
status: &str,
wall_time: Duration,
) {
let wall_time_seconds = ((wall_time.as_secs_f32()) * 10.0).round() / 10.0;
let header = format!(
"{}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n",
match status {
CodeModeExecutionStatus::Completed => "Script completed".to_string(),
CodeModeExecutionStatus::Failed => "Script failed".to_string(),
CodeModeExecutionStatus::Running(cell_id) => {
format!("Script running with cell ID {cell_id}")
}
CodeModeExecutionStatus::Terminated => "Script terminated".to_string(),
}
);
let header = format!("{status}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n");
content_items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
}
fn truncate_code_mode_result(
items: Vec<FunctionCallOutputContentItem>,
max_output_tokens_per_exec_call: Option<usize>,
max_output_tokens: Option<usize>,
) -> Vec<FunctionCallOutputContentItem> {
let max_output_tokens = resolve_max_tokens(max_output_tokens_per_exec_call);
let max_output_tokens = resolve_max_tokens(max_output_tokens);
let policy = TruncationPolicy::Tokens(max_output_tokens);
if items
.iter()
@@ -216,21 +240,9 @@ fn truncate_code_mode_result(
truncate_function_output_items_with_policy(&items, policy)
}
fn output_content_items_from_json_values(
content_items: Vec<JsonValue>,
) -> Result<Vec<FunctionCallOutputContentItem>, String> {
content_items
.into_iter()
.enumerate()
.map(|(index, item)| {
serde_json::from_value(item).map_err(|err| {
format!("invalid {PUBLIC_TOOL_NAME} content item at index {index}: {err}")
})
})
.collect()
}
async fn build_enabled_tools(exec: &ExecContext) -> Vec<protocol::EnabledTool> {
pub(super) async fn build_enabled_tools(
exec: &ExecContext,
) -> Vec<codex_code_mode::ToolDefinition> {
let router = build_nested_router(exec).await;
let mut out = router
.specs()
@@ -238,39 +250,37 @@ async fn build_enabled_tools(exec: &ExecContext) -> Vec<protocol::EnabledTool> {
.map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true))
.filter_map(enabled_tool_from_spec)
.collect::<Vec<_>>();
out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name));
out.dedup_by(|left, right| left.tool_name == right.tool_name);
out.sort_by(|left, right| left.name.cmp(&right.name));
out.dedup_by(|left, right| left.name == right.name);
out
}
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<protocol::EnabledTool> {
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<codex_code_mode::ToolDefinition> {
let tool_name = spec.name().to_string();
if !is_code_mode_nested_tool(&tool_name) {
if !codex_code_mode::is_code_mode_nested_tool(&tool_name) {
return None;
}
let reference = code_mode_tool_reference(&tool_name);
let global_name = normalize_code_mode_identifier(&tool_name);
let (description, kind) = match spec {
ToolSpec::Function(tool) => (tool.description, protocol::CodeModeToolKind::Function),
ToolSpec::Freeform(tool) => (tool.description, protocol::CodeModeToolKind::Freeform),
match spec {
ToolSpec::Function(tool) => Some(codex_code_mode::ToolDefinition {
name: tool_name,
description: tool.description,
kind: codex_code_mode::CodeModeToolKind::Function,
input_schema: serde_json::to_value(&tool.parameters).ok(),
output_schema: tool.output_schema,
}),
ToolSpec::Freeform(tool) => Some(codex_code_mode::ToolDefinition {
name: tool_name,
description: tool.description,
kind: codex_code_mode::CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
}),
ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => {
return None;
}
};
Some(protocol::EnabledTool {
tool_name,
global_name,
module_path: reference.module_path,
namespace: reference.namespace,
name: normalize_code_mode_identifier(&reference.tool_key),
description,
kind,
})
| ToolSpec::WebSearch { .. } => None,
}
}
async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
@@ -303,7 +313,7 @@ async fn call_nested_tool(
tool_runtime: ToolCallRuntime,
tool_name: String,
input: Option<JsonValue>,
cancellation_token: tokio_util::sync::CancellationToken,
cancellation_token: CancellationToken,
) -> Result<JsonValue, FunctionCallError> {
if tool_name == PUBLIC_TOOL_NAME {
return Err(FunctionCallError::RespondToModel(format!(
@@ -340,18 +350,18 @@ async fn call_nested_tool(
Ok(result.code_mode_result())
}
fn tool_kind_for_spec(spec: &ToolSpec) -> protocol::CodeModeToolKind {
fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind {
if matches!(spec, ToolSpec::Freeform(_)) {
protocol::CodeModeToolKind::Freeform
codex_code_mode::CodeModeToolKind::Freeform
} else {
protocol::CodeModeToolKind::Function
codex_code_mode::CodeModeToolKind::Function
}
}
fn tool_kind_for_name(
spec: Option<ToolSpec>,
tool_name: &str,
) -> Result<protocol::CodeModeToolKind, String> {
) -> Result<codex_code_mode::CodeModeToolKind, String> {
spec.as_ref()
.map(tool_kind_for_spec)
.ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}"))
@@ -364,8 +374,12 @@ fn build_nested_tool_payload(
) -> Result<ToolPayload, String> {
let actual_kind = tool_kind_for_name(spec, tool_name)?;
match actual_kind {
protocol::CodeModeToolKind::Function => build_function_tool_payload(tool_name, input),
protocol::CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input),
codex_code_mode::CodeModeToolKind::Function => {
build_function_tool_payload(tool_name, input)
}
codex_code_mode::CodeModeToolKind::Freeform => {
build_freeform_tool_payload(tool_name, input)
}
}
}
@@ -1,173 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tracing::warn;
use super::CODE_MODE_RUNNER_SOURCE;
use super::PUBLIC_TOOL_NAME;
use super::protocol::HostToNodeMessage;
use super::protocol::NodeToHostMessage;
use super::protocol::message_request_id;
pub(super) struct CodeModeProcess {
pub(super) child: tokio::process::Child,
pub(super) stdin: Arc<Mutex<tokio::process::ChildStdin>>,
pub(super) stdout_task: JoinHandle<()>,
pub(super) response_waiters: Arc<Mutex<HashMap<String, oneshot::Sender<NodeToHostMessage>>>>,
pub(super) message_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeToHostMessage>>>,
}
impl CodeModeProcess {
pub(super) async fn send(
&mut self,
request_id: &str,
message: &HostToNodeMessage,
) -> Result<NodeToHostMessage, std::io::Error> {
if self.stdout_task.is_finished() {
return Err(std::io::Error::other(format!(
"{PUBLIC_TOOL_NAME} runner is not available"
)));
}
let (tx, rx) = oneshot::channel();
self.response_waiters
.lock()
.await
.insert(request_id.to_string(), tx);
if let Err(err) = write_message(&self.stdin, message).await {
self.response_waiters.lock().await.remove(request_id);
return Err(err);
}
match rx.await {
Ok(message) => Ok(message),
Err(_) => Err(std::io::Error::other(format!(
"{PUBLIC_TOOL_NAME} runner is not available"
))),
}
}
pub(super) fn has_exited(&mut self) -> Result<bool, std::io::Error> {
self.child
.try_wait()
.map(|status| status.is_some())
.map_err(std::io::Error::other)
}
}
pub(super) async fn spawn_code_mode_process(
node_path: &std::path::Path,
) -> Result<CodeModeProcess, std::io::Error> {
let mut cmd = tokio::process::Command::new(node_path);
cmd.arg("--experimental-vm-modules");
cmd.arg("--eval");
cmd.arg(CODE_MODE_RUNNER_SOURCE);
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(std::io::Error::other)?;
let stdout = child.stdout.take().ok_or_else(|| {
std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdout"))
})?;
let stderr = child.stderr.take().ok_or_else(|| {
std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stderr"))
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdin")))?;
let stdin = Arc::new(Mutex::new(stdin));
let response_waiters = Arc::new(Mutex::new(HashMap::<
String,
oneshot::Sender<NodeToHostMessage>,
>::new()));
let (message_tx, message_rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut buf = Vec::new();
match reader.read_to_end(&mut buf).await {
Ok(_) => {
let stderr = String::from_utf8_lossy(&buf).trim().to_string();
if !stderr.is_empty() {
warn!("{PUBLIC_TOOL_NAME} runner stderr: {stderr}");
}
}
Err(err) => {
warn!("failed to read {PUBLIC_TOOL_NAME} stderr: {err}");
}
}
});
let stdout_task = tokio::spawn({
let response_waiters = Arc::clone(&response_waiters);
async move {
let mut stdout_lines = BufReader::new(stdout).lines();
loop {
let line = match stdout_lines.next_line().await {
Ok(line) => line,
Err(err) => {
warn!("failed to read {PUBLIC_TOOL_NAME} stdout: {err}");
break;
}
};
let Some(line) = line else {
break;
};
if line.trim().is_empty() {
continue;
}
let message: NodeToHostMessage = match serde_json::from_str(&line) {
Ok(message) => message,
Err(err) => {
warn!("failed to parse {PUBLIC_TOOL_NAME} stdout message: {err}");
break;
}
};
match message {
message @ (NodeToHostMessage::ToolCall { .. }
| NodeToHostMessage::Notify { .. }) => {
let _ = message_tx.send(message);
}
message => {
if let Some(request_id) = message_request_id(&message)
&& let Some(waiter) = response_waiters.lock().await.remove(request_id)
{
let _ = waiter.send(message);
}
}
}
}
response_waiters.lock().await.clear();
}
});
Ok(CodeModeProcess {
child,
stdin,
stdout_task,
response_waiters,
message_rx: Arc::new(Mutex::new(message_rx)),
})
}
pub(super) async fn write_message(
stdin: &Arc<Mutex<tokio::process::ChildStdin>>,
message: &HostToNodeMessage,
) -> Result<(), std::io::Error> {
let line = serde_json::to_string(message).map_err(std::io::Error::other)?;
let mut stdin = stdin.lock().await;
stdin.write_all(line.as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.flush().await?;
Ok(())
}
@@ -1,169 +0,0 @@
use std::collections::HashMap;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use super::CODE_MODE_BRIDGE_SOURCE;
use super::PUBLIC_TOOL_NAME;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum CodeModeToolKind {
Function,
Freeform,
}
#[derive(Clone, Debug, Serialize)]
pub(super) struct EnabledTool {
pub(super) tool_name: String,
pub(super) global_name: String,
#[serde(rename = "module")]
pub(super) module_path: String,
pub(super) namespace: Vec<String>,
pub(super) name: String,
pub(super) description: String,
pub(super) kind: CodeModeToolKind,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) struct CodeModeToolCall {
pub(super) request_id: String,
pub(super) id: String,
pub(super) name: String,
#[serde(default)]
pub(super) input: Option<JsonValue>,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct CodeModeNotify {
pub(super) cell_id: String,
pub(super) call_id: String,
pub(super) text: String,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum HostToNodeMessage {
Start {
request_id: String,
cell_id: String,
tool_call_id: String,
default_yield_time_ms: u64,
enabled_tools: Vec<EnabledTool>,
stored_values: HashMap<String, JsonValue>,
source: String,
yield_time_ms: Option<u64>,
max_output_tokens: Option<usize>,
},
Poll {
request_id: String,
cell_id: String,
yield_time_ms: u64,
},
Terminate {
request_id: String,
cell_id: String,
},
Response {
request_id: String,
id: String,
code_mode_result: JsonValue,
#[serde(default)]
error_text: Option<String>,
},
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum NodeToHostMessage {
ToolCall {
#[serde(flatten)]
tool_call: CodeModeToolCall,
},
Yielded {
request_id: String,
content_items: Vec<JsonValue>,
},
Terminated {
request_id: String,
content_items: Vec<JsonValue>,
},
Notify {
#[serde(flatten)]
notify: CodeModeNotify,
},
Result {
request_id: String,
content_items: Vec<JsonValue>,
stored_values: HashMap<String, JsonValue>,
#[serde(default)]
error_text: Option<String>,
#[serde(default)]
max_output_tokens_per_exec_call: Option<usize>,
},
}
pub(super) fn build_source(
user_code: &str,
enabled_tools: &[EnabledTool],
) -> Result<String, String> {
let enabled_tools_json = serde_json::to_string(enabled_tools)
.map_err(|err| format!("failed to serialize enabled tools: {err}"))?;
Ok(CODE_MODE_BRIDGE_SOURCE
.replace(
"__CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__",
&enabled_tools_json,
)
.replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code))
}
pub(super) fn message_request_id(message: &NodeToHostMessage) -> Option<&str> {
match message {
NodeToHostMessage::ToolCall { .. } => None,
NodeToHostMessage::Yielded { request_id, .. }
| NodeToHostMessage::Terminated { request_id, .. }
| NodeToHostMessage::Result { request_id, .. } => Some(request_id),
NodeToHostMessage::Notify { .. } => None,
}
}
pub(super) fn unexpected_tool_call_error() -> String {
format!("{PUBLIC_TOOL_NAME} received an unexpected tool call response")
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::CodeModeNotify;
use super::NodeToHostMessage;
use super::message_request_id;
#[test]
fn message_request_id_absent_for_notify() {
let message = NodeToHostMessage::Notify {
notify: CodeModeNotify {
cell_id: "1".to_string(),
call_id: "call-1".to_string(),
text: "hello".to_string(),
},
};
assert_eq!(None, message_request_id(&message));
}
#[test]
fn message_request_id_present_for_result() {
let message = NodeToHostMessage::Result {
request_id: "req-1".to_string(),
content_items: Vec::new(),
stored_values: HashMap::new(),
error_text: None,
max_output_tokens_per_exec_call: None,
};
assert_eq!(Some("req-1"), message_request_id(&message));
}
}
@@ -0,0 +1,44 @@
use codex_code_mode::ImageDetail as CodeModeImageDetail;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
trait IntoProtocol<T> {
fn into_protocol(self) -> T;
}
pub(super) fn into_function_call_output_content_items(
items: Vec<codex_code_mode::FunctionCallOutputContentItem>,
) -> Vec<FunctionCallOutputContentItem> {
items.into_iter().map(IntoProtocol::into_protocol).collect()
}
impl IntoProtocol<ImageDetail> for CodeModeImageDetail {
fn into_protocol(self) -> ImageDetail {
let value = self;
match value {
CodeModeImageDetail::Auto => ImageDetail::Auto,
CodeModeImageDetail::Low => ImageDetail::Low,
CodeModeImageDetail::High => ImageDetail::High,
CodeModeImageDetail::Original => ImageDetail::Original,
}
}
}
impl IntoProtocol<FunctionCallOutputContentItem>
for codex_code_mode::FunctionCallOutputContentItem
{
fn into_protocol(self) -> FunctionCallOutputContentItem {
let value = self;
match value {
codex_code_mode::FunctionCallOutputContentItem::InputText { text } => {
FunctionCallOutputContentItem::InputText { text }
}
codex_code_mode::FunctionCallOutputContentItem::InputImage { image_url, detail } => {
FunctionCallOutputContentItem::InputImage {
image_url,
detail: detail.map(IntoProtocol::into_protocol),
}
}
}
}
}
@@ -1,938 +0,0 @@
'use strict';
const readline = require('node:readline');
const { Worker } = require('node:worker_threads');
const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000;
function normalizeMaxOutputTokensPerExecCall(value) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer');
}
return value;
}
function normalizeYieldTime(value) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError('yield_time must be a non-negative safe integer');
}
return value;
}
function formatErrorText(error) {
return String(error && error.stack ? error.stack : error);
}
function cloneJsonValue(value) {
return JSON.parse(JSON.stringify(value));
}
function clearTimer(timer) {
if (timer !== null) {
clearTimeout(timer);
}
return null;
}
function takeContentItems(session) {
const clonedContentItems = cloneJsonValue(session.content_items);
session.content_items.splice(0, session.content_items.length);
return Array.isArray(clonedContentItems) ? clonedContentItems : [];
}
function codeModeWorkerMain() {
'use strict';
const { parentPort, workerData } = require('node:worker_threads');
const vm = require('node:vm');
const { SourceTextModule, SyntheticModule } = vm;
function formatErrorText(error) {
return String(error && error.stack ? error.stack : error);
}
function cloneJsonValue(value) {
return JSON.parse(JSON.stringify(value));
}
class CodeModeExitSignal extends Error {
constructor() {
super('code mode exit');
this.name = 'CodeModeExitSignal';
}
}
function isCodeModeExitSignal(error) {
return error instanceof CodeModeExitSignal;
}
function createToolCaller() {
let nextId = 0;
const pending = new Map();
parentPort.on('message', (message) => {
if (message.type === 'tool_response') {
const entry = pending.get(message.id);
if (!entry) {
return;
}
pending.delete(message.id);
entry.resolve(message.result ?? '');
return;
}
if (message.type === 'tool_response_error') {
const entry = pending.get(message.id);
if (!entry) {
return;
}
pending.delete(message.id);
entry.reject(new Error(message.error_text ?? 'tool call failed'));
return;
}
});
return (name, input) => {
const id = 'msg-' + ++nextId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
parentPort.postMessage({
type: 'tool_call',
id,
name: String(name),
input,
});
});
};
}
function createContentItems() {
const contentItems = [];
const push = contentItems.push.bind(contentItems);
contentItems.push = (...items) => {
for (const item of items) {
parentPort.postMessage({
type: 'content_item',
item: cloneJsonValue(item),
});
}
return push(...items);
};
parentPort.on('message', (message) => {
if (message.type === 'clear_content') {
contentItems.splice(0, contentItems.length);
}
});
return contentItems;
}
function createGlobalToolsNamespace(callTool, enabledTools) {
const tools = Object.create(null);
for (const { tool_name, global_name } of enabledTools) {
Object.defineProperty(tools, global_name, {
value: async (args) => callTool(tool_name, args),
configurable: false,
enumerable: true,
writable: false,
});
}
return Object.freeze(tools);
}
function createModuleToolsNamespace(callTool, enabledTools) {
const tools = Object.create(null);
for (const { tool_name, global_name } of enabledTools) {
Object.defineProperty(tools, global_name, {
value: async (args) => callTool(tool_name, args),
configurable: false,
enumerable: true,
writable: false,
});
}
return Object.freeze(tools);
}
function createAllToolsMetadata(enabledTools) {
return Object.freeze(
enabledTools.map(({ global_name, description }) =>
Object.freeze({
name: global_name,
description,
})
)
);
}
function createToolsModule(context, callTool, enabledTools) {
const tools = createModuleToolsNamespace(callTool, enabledTools);
const allTools = createAllToolsMetadata(enabledTools);
const exportNames = ['ALL_TOOLS'];
for (const { global_name } of enabledTools) {
if (global_name !== 'ALL_TOOLS') {
exportNames.push(global_name);
}
}
const uniqueExportNames = [...new Set(exportNames)];
return new SyntheticModule(
uniqueExportNames,
function initToolsModule() {
this.setExport('ALL_TOOLS', allTools);
for (const exportName of uniqueExportNames) {
if (exportName !== 'ALL_TOOLS') {
this.setExport(exportName, tools[exportName]);
}
}
},
{ context }
);
}
function ensureContentItems(context) {
if (!Array.isArray(context.__codexContentItems)) {
context.__codexContentItems = [];
}
return context.__codexContentItems;
}
function serializeOutputText(value) {
if (typeof value === 'string') {
return value;
}
if (
typeof value === 'undefined' ||
value === null ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'bigint'
) {
return String(value);
}
const serialized = JSON.stringify(value);
if (typeof serialized === 'string') {
return serialized;
}
return String(value);
}
function normalizeOutputImage(value) {
let imageUrl;
let detail;
if (typeof value === 'string') {
imageUrl = value;
} else if (
value &&
typeof value === 'object' &&
!Array.isArray(value)
) {
if (typeof value.image_url === 'string') {
imageUrl = value.image_url;
}
if (typeof value.detail === 'string') {
detail = value.detail;
} else if (
Object.prototype.hasOwnProperty.call(value, 'detail') &&
value.detail !== null &&
typeof value.detail !== 'undefined'
) {
throw new TypeError('image detail must be a string when provided');
}
}
if (typeof imageUrl !== 'string' || !imageUrl) {
throw new TypeError(
'image expects a non-empty image URL string or an object with image_url and optional detail'
);
}
if (!/^(?:https?:\/\/|data:)/i.test(imageUrl)) {
throw new TypeError('image expects an http(s) or data URL');
}
if (typeof detail !== 'undefined' && !/^(?:auto|low|high|original)$/i.test(detail)) {
throw new TypeError('image detail must be one of: auto, low, high, original');
}
const normalized = { image_url: imageUrl };
if (typeof detail === 'string') {
normalized.detail = detail.toLowerCase();
}
return normalized;
}
function createCodeModeHelpers(context, state, toolCallId) {
const load = (key) => {
if (typeof key !== 'string') {
throw new TypeError('load key must be a string');
}
if (!Object.prototype.hasOwnProperty.call(state.storedValues, key)) {
return undefined;
}
return cloneJsonValue(state.storedValues[key]);
};
const store = (key, value) => {
if (typeof key !== 'string') {
throw new TypeError('store key must be a string');
}
state.storedValues[key] = cloneJsonValue(value);
};
const text = (value) => {
const item = {
type: 'input_text',
text: serializeOutputText(value),
};
ensureContentItems(context).push(item);
return item;
};
const image = (value) => {
const item = Object.assign({ type: 'input_image' }, normalizeOutputImage(value));
ensureContentItems(context).push(item);
return item;
};
const yieldControl = () => {
parentPort.postMessage({ type: 'yield' });
};
const notify = (value) => {
const text = serializeOutputText(value);
if (text.trim().length === 0) {
throw new TypeError('notify expects non-empty text');
}
if (typeof toolCallId !== 'string' || toolCallId.length === 0) {
throw new TypeError('notify requires a valid tool call id');
}
parentPort.postMessage({
type: 'notify',
call_id: toolCallId,
text,
});
return text;
};
const exit = () => {
throw new CodeModeExitSignal();
};
return Object.freeze({
exit,
image,
load,
notify,
output_image: image,
output_text: text,
store,
text,
yield_control: yieldControl,
});
}
function createCodeModeModule(context, helpers) {
return new SyntheticModule(
[
'exit',
'image',
'load',
'notify',
'output_text',
'output_image',
'store',
'text',
'yield_control',
],
function initCodeModeModule() {
this.setExport('exit', helpers.exit);
this.setExport('image', helpers.image);
this.setExport('load', helpers.load);
this.setExport('notify', helpers.notify);
this.setExport('output_text', helpers.output_text);
this.setExport('output_image', helpers.output_image);
this.setExport('store', helpers.store);
this.setExport('text', helpers.text);
this.setExport('yield_control', helpers.yield_control);
},
{ context }
);
}
function createBridgeRuntime(callTool, enabledTools, helpers) {
return Object.freeze({
ALL_TOOLS: createAllToolsMetadata(enabledTools),
exit: helpers.exit,
image: helpers.image,
load: helpers.load,
notify: helpers.notify,
store: helpers.store,
text: helpers.text,
tools: createGlobalToolsNamespace(callTool, enabledTools),
yield_control: helpers.yield_control,
});
}
function namespacesMatch(left, right) {
if (left.length !== right.length) {
return false;
}
return left.every((segment, index) => segment === right[index]);
}
function createNamespacedToolsNamespace(callTool, enabledTools, namespace) {
const tools = Object.create(null);
for (const tool of enabledTools) {
const toolNamespace = Array.isArray(tool.namespace) ? tool.namespace : [];
if (!namespacesMatch(toolNamespace, namespace)) {
continue;
}
Object.defineProperty(tools, tool.name, {
value: async (args) => callTool(tool.tool_name, args),
configurable: false,
enumerable: true,
writable: false,
});
}
return Object.freeze(tools);
}
function createNamespacedToolsModule(context, callTool, enabledTools, namespace) {
const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace);
const exportNames = [];
for (const exportName of Object.keys(tools)) {
if (exportName !== 'ALL_TOOLS') {
exportNames.push(exportName);
}
}
const uniqueExportNames = [...new Set(exportNames)];
return new SyntheticModule(
uniqueExportNames,
function initNamespacedToolsModule() {
for (const exportName of uniqueExportNames) {
this.setExport(exportName, tools[exportName]);
}
},
{ context }
);
}
function createModuleResolver(context, callTool, enabledTools, helpers) {
let toolsModule;
let codeModeModule;
const namespacedModules = new Map();
return function resolveModule(specifier) {
if (specifier === 'tools.js') {
toolsModule ??= createToolsModule(context, callTool, enabledTools);
return toolsModule;
}
if (specifier === '@openai/code_mode' || specifier === 'openai/code_mode') {
codeModeModule ??= createCodeModeModule(context, helpers);
return codeModeModule;
}
const namespacedMatch = /^tools\/(.+)\.js$/.exec(specifier);
if (!namespacedMatch) {
throw new Error('Unsupported import in exec: ' + specifier);
}
const namespace = namespacedMatch[1]
.split('/')
.filter((segment) => segment.length > 0);
if (namespace.length === 0) {
throw new Error('Unsupported import in exec: ' + specifier);
}
const cacheKey = namespace.join('/');
if (!namespacedModules.has(cacheKey)) {
namespacedModules.set(
cacheKey,
createNamespacedToolsModule(context, callTool, enabledTools, namespace)
);
}
return namespacedModules.get(cacheKey);
};
}
async function resolveDynamicModule(specifier, resolveModule) {
const module = resolveModule(specifier);
if (module.status === 'unlinked') {
await module.link(resolveModule);
}
if (module.status === 'linked' || module.status === 'evaluating') {
await module.evaluate();
}
if (module.status === 'errored') {
throw module.error;
}
return module;
}
async function runModule(context, start, callTool, helpers) {
const resolveModule = createModuleResolver(
context,
callTool,
start.enabled_tools ?? [],
helpers
);
const mainModule = new SourceTextModule(start.source, {
context,
identifier: 'exec_main.mjs',
importModuleDynamically: async (specifier) =>
resolveDynamicModule(specifier, resolveModule),
});
await mainModule.link(resolveModule);
await mainModule.evaluate();
}
async function main() {
const start = workerData ?? {};
const toolCallId = start.tool_call_id;
const state = {
storedValues: cloneJsonValue(start.stored_values ?? {}),
};
const callTool = createToolCaller();
const enabledTools = start.enabled_tools ?? [];
const contentItems = createContentItems();
const context = vm.createContext({
__codexContentItems: contentItems,
});
const helpers = createCodeModeHelpers(context, state, toolCallId);
Object.defineProperty(context, '__codexRuntime', {
value: createBridgeRuntime(callTool, enabledTools, helpers),
configurable: true,
enumerable: false,
writable: false,
});
parentPort.postMessage({ type: 'started' });
try {
await runModule(context, start, callTool, helpers);
parentPort.postMessage({
type: 'result',
stored_values: state.storedValues,
});
} catch (error) {
if (isCodeModeExitSignal(error)) {
parentPort.postMessage({
type: 'result',
stored_values: state.storedValues,
});
return;
}
parentPort.postMessage({
type: 'result',
stored_values: state.storedValues,
error_text: formatErrorText(error),
});
}
}
void main().catch((error) => {
parentPort.postMessage({
type: 'result',
stored_values: {},
error_text: formatErrorText(error),
});
});
}
function createProtocol() {
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
let nextId = 0;
const pending = new Map();
const sessions = new Map();
let closedResolve;
const closed = new Promise((resolve) => {
closedResolve = resolve;
});
rl.on('line', (line) => {
if (!line.trim()) {
return;
}
let message;
try {
message = JSON.parse(line);
} catch (error) {
process.stderr.write(formatErrorText(error) + '\n');
return;
}
if (message.type === 'start') {
startSession(protocol, sessions, message);
return;
}
if (message.type === 'poll') {
const session = sessions.get(message.cell_id);
if (session) {
session.request_id = String(message.request_id);
if (session.pending_result) {
void completeSession(protocol, sessions, session, session.pending_result);
} else {
schedulePollYield(protocol, session, normalizeYieldTime(message.yield_time_ms ?? 0));
}
} else {
void protocol.send({
type: 'result',
request_id: message.request_id,
content_items: [],
stored_values: {},
error_text: `exec cell ${message.cell_id} not found`,
max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL,
});
}
return;
}
if (message.type === 'terminate') {
const session = sessions.get(message.cell_id);
if (session) {
session.request_id = String(message.request_id);
void terminateSession(protocol, sessions, session);
} else {
void protocol.send({
type: 'result',
request_id: message.request_id,
content_items: [],
stored_values: {},
error_text: `exec cell ${message.cell_id} not found`,
max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL,
});
}
return;
}
if (message.type === 'response') {
const entry = pending.get(message.request_id + ':' + message.id);
if (!entry) {
return;
}
pending.delete(message.request_id + ':' + message.id);
if (typeof message.error_text === 'string') {
entry.reject(new Error(message.error_text));
return;
}
entry.resolve(message.code_mode_result ?? '');
return;
}
process.stderr.write('Unknown protocol message type: ' + message.type + '\n');
});
rl.on('close', () => {
const error = new Error('stdin closed');
for (const entry of pending.values()) {
entry.reject(error);
}
pending.clear();
for (const session of sessions.values()) {
session.initial_yield_timer = clearTimer(session.initial_yield_timer);
session.poll_yield_timer = clearTimer(session.poll_yield_timer);
void session.worker.terminate().catch(() => {});
}
sessions.clear();
closedResolve();
});
function send(message) {
return new Promise((resolve, reject) => {
process.stdout.write(JSON.stringify(message) + '\n', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
function request(type, payload) {
const requestId = 'req-' + ++nextId;
const id = 'msg-' + ++nextId;
const pendingKey = requestId + ':' + id;
return new Promise((resolve, reject) => {
pending.set(pendingKey, { resolve, reject });
void send({ type, request_id: requestId, id, ...payload }).catch((error) => {
pending.delete(pendingKey);
reject(error);
});
});
}
const protocol = { closed, request, send };
return protocol;
}
function sessionWorkerSource() {
return '(' + codeModeWorkerMain.toString() + ')();';
}
function startSession(protocol, sessions, start) {
if (typeof start.tool_call_id !== 'string' || start.tool_call_id.length === 0) {
throw new TypeError('start requires a valid tool_call_id');
}
const maxOutputTokensPerExecCall =
start.max_output_tokens == null
? DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL
: normalizeMaxOutputTokensPerExecCall(start.max_output_tokens);
const session = {
completed: false,
content_items: [],
default_yield_time_ms: normalizeYieldTime(start.default_yield_time_ms),
id: start.cell_id,
initial_yield_time_ms:
start.yield_time_ms == null
? normalizeYieldTime(start.default_yield_time_ms)
: normalizeYieldTime(start.yield_time_ms),
initial_yield_timer: null,
initial_yield_triggered: false,
max_output_tokens_per_exec_call: maxOutputTokensPerExecCall,
pending_result: null,
poll_yield_timer: null,
request_id: String(start.request_id),
worker: new Worker(sessionWorkerSource(), {
eval: true,
workerData: start,
}),
};
sessions.set(session.id, session);
session.worker.on('message', (message) => {
void handleWorkerMessage(protocol, sessions, session, message).catch((error) => {
void completeSession(protocol, sessions, session, {
type: 'result',
stored_values: {},
error_text: formatErrorText(error),
});
});
});
session.worker.on('error', (error) => {
void completeSession(protocol, sessions, session, {
type: 'result',
stored_values: {},
error_text: formatErrorText(error),
});
});
session.worker.on('exit', (code) => {
if (code !== 0 && !session.completed) {
void completeSession(protocol, sessions, session, {
type: 'result',
stored_values: {},
error_text: 'exec worker exited with code ' + code,
});
}
});
}
async function handleWorkerMessage(protocol, sessions, session, message) {
if (session.completed) {
return;
}
if (message.type === 'content_item') {
session.content_items.push(cloneJsonValue(message.item));
return;
}
if (message.type === 'started') {
scheduleInitialYield(protocol, session, session.initial_yield_time_ms);
return;
}
if (message.type === 'yield') {
void sendYielded(protocol, session);
return;
}
if (message.type === 'notify') {
if (typeof message.text !== 'string' || message.text.trim().length === 0) {
throw new TypeError('notify requires non-empty text');
}
if (typeof message.call_id !== 'string' || message.call_id.length === 0) {
throw new TypeError('notify requires a valid call id');
}
await protocol.send({
type: 'notify',
cell_id: session.id,
call_id: message.call_id,
text: message.text,
});
return;
}
if (message.type === 'tool_call') {
void forwardToolCall(protocol, session, message);
return;
}
if (message.type === 'result') {
const result = {
type: 'result',
stored_values: cloneJsonValue(message.stored_values ?? {}),
error_text:
typeof message.error_text === 'string' ? message.error_text : undefined,
};
if (session.request_id === null) {
session.pending_result = result;
session.initial_yield_timer = clearTimer(session.initial_yield_timer);
session.poll_yield_timer = clearTimer(session.poll_yield_timer);
return;
}
await completeSession(protocol, sessions, session, result);
return;
}
process.stderr.write('Unknown worker message type: ' + message.type + '\n');
}
async function forwardToolCall(protocol, session, message) {
try {
const result = await protocol.request('tool_call', {
name: String(message.name),
input: message.input,
});
if (session.completed) {
return;
}
try {
session.worker.postMessage({
type: 'tool_response',
id: message.id,
result,
});
} catch {}
} catch (error) {
if (session.completed) {
return;
}
try {
session.worker.postMessage({
type: 'tool_response_error',
id: message.id,
error_text: formatErrorText(error),
});
} catch {}
}
}
async function sendYielded(protocol, session) {
if (session.completed || session.request_id === null) {
return;
}
session.initial_yield_timer = clearTimer(session.initial_yield_timer);
session.initial_yield_triggered = true;
session.poll_yield_timer = clearTimer(session.poll_yield_timer);
const contentItems = takeContentItems(session);
const requestId = session.request_id;
try {
session.worker.postMessage({ type: 'clear_content' });
} catch {}
await protocol.send({
type: 'yielded',
request_id: requestId,
content_items: contentItems,
});
session.request_id = null;
}
function scheduleInitialYield(protocol, session, yieldTime) {
if (session.completed || session.initial_yield_triggered) {
return yieldTime;
}
session.initial_yield_timer = clearTimer(session.initial_yield_timer);
session.initial_yield_timer = setTimeout(() => {
session.initial_yield_timer = null;
session.initial_yield_triggered = true;
void sendYielded(protocol, session);
}, yieldTime);
return yieldTime;
}
function schedulePollYield(protocol, session, yieldTime) {
if (session.completed) {
return;
}
session.poll_yield_timer = clearTimer(session.poll_yield_timer);
session.poll_yield_timer = setTimeout(() => {
session.poll_yield_timer = null;
void sendYielded(protocol, session);
}, yieldTime);
}
async function completeSession(protocol, sessions, session, message) {
if (session.completed) {
return;
}
if (session.request_id === null) {
session.pending_result = message;
session.initial_yield_timer = clearTimer(session.initial_yield_timer);
session.poll_yield_timer = clearTimer(session.poll_yield_timer);
return;
}
const requestId = session.request_id;
session.completed = true;
session.initial_yield_timer = clearTimer(session.initial_yield_timer);
session.poll_yield_timer = clearTimer(session.poll_yield_timer);
sessions.delete(session.id);
const contentItems = takeContentItems(session);
session.pending_result = null;
try {
session.worker.postMessage({ type: 'clear_content' });
} catch {}
await protocol.send({
...message,
request_id: requestId,
content_items: contentItems,
max_output_tokens_per_exec_call: session.max_output_tokens_per_exec_call,
});
}
async function terminateSession(protocol, sessions, session) {
if (session.completed) {
return;
}
session.completed = true;
session.initial_yield_timer = clearTimer(session.initial_yield_timer);
session.poll_yield_timer = clearTimer(session.poll_yield_timer);
sessions.delete(session.id);
const contentItems = takeContentItems(session);
try {
await session.worker.terminate();
} catch {}
await protocol.send({
type: 'terminated',
request_id: session.request_id,
content_items: contentItems,
});
}
async function main() {
const protocol = createProtocol();
await protocol.closed;
}
void main().catch(async (error) => {
try {
process.stderr.write(formatErrorText(error) + '\n');
} finally {
process.exitCode = 1;
}
});
@@ -1,108 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::Value as JsonValue;
use tokio::sync::Mutex;
use tracing::warn;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::parallel::ToolCallRuntime;
use codex_features::Feature;
use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
use super::process::CodeModeProcess;
use super::process::spawn_code_mode_process;
use super::worker::CodeModeWorker;
pub(crate) struct CodeModeService {
js_repl_node_path: Option<PathBuf>,
stored_values: Mutex<HashMap<String, JsonValue>>,
process: Arc<Mutex<Option<CodeModeProcess>>>,
next_cell_id: Mutex<u64>,
}
impl CodeModeService {
pub(crate) fn new(js_repl_node_path: Option<PathBuf>) -> Self {
Self {
js_repl_node_path,
stored_values: Mutex::new(HashMap::new()),
process: Arc::new(Mutex::new(None)),
next_cell_id: Mutex::new(1),
}
}
pub(crate) async fn stored_values(&self) -> HashMap<String, JsonValue> {
self.stored_values.lock().await.clone()
}
pub(crate) async fn replace_stored_values(&self, values: HashMap<String, JsonValue>) {
*self.stored_values.lock().await = values;
}
pub(super) async fn ensure_started(
&self,
) -> Result<tokio::sync::OwnedMutexGuard<Option<CodeModeProcess>>, std::io::Error> {
let mut process_slot = self.process.lock().await;
let needs_spawn = match process_slot.as_mut() {
Some(process) => !matches!(process.has_exited(), Ok(false)),
None => true,
};
if needs_spawn {
let node_path = resolve_compatible_node(self.js_repl_node_path.as_deref())
.await
.map_err(std::io::Error::other)?;
*process_slot = Some(spawn_code_mode_process(&node_path).await?);
}
drop(process_slot);
Ok(self.process.clone().lock_owned().await)
}
pub(crate) async fn start_turn_worker(
&self,
session: &Arc<Session>,
turn: &Arc<TurnContext>,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> Option<CodeModeWorker> {
if !turn.features.enabled(Feature::CodeMode) {
return None;
}
let exec = ExecContext {
session: Arc::clone(session),
turn: Arc::clone(turn),
};
let tool_runtime =
ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker);
let mut process_slot = match self.ensure_started().await {
Ok(process_slot) => process_slot,
Err(err) => {
warn!("failed to start {PUBLIC_TOOL_NAME} worker for turn: {err}");
return None;
}
};
let Some(process) = process_slot.as_mut() else {
warn!(
"failed to start {PUBLIC_TOOL_NAME} worker for turn: {PUBLIC_TOOL_NAME} runner failed to start"
);
return None;
};
Some(process.worker(exec, tool_runtime))
}
pub(crate) async fn allocate_cell_id(&self) -> String {
let mut next_cell_id = self.next_cell_id.lock().await;
let cell_id = *next_cell_id;
*next_cell_id = next_cell_id.saturating_add(1);
cell_id.to_string()
}
pub(crate) async fn allocate_request_id(&self) -> String {
uuid::Uuid::new_v4().to_string()
}
}
@@ -1,8 +0,0 @@
- Use `wait` only after `exec` returns `Script running with cell ID ...`.
- `cell_id` identifies the running `exec` cell to resume.
- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout.
- `max_tokens` limits how much new output this wait call returns.
- `terminate: true` stops the running cell instead of waiting for more output.
- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
- If the cell is still running, `wait` may yield again with the same `cell_id`.
- If the cell has already finished, `wait` returns the completed result and closes the cell.
@@ -8,13 +8,10 @@ use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use super::CodeModeSessionProgress;
use super::DEFAULT_WAIT_YIELD_TIME_MS;
use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
use super::WAIT_TOOL_NAME;
use super::handle_node_message;
use super::protocol::HostToNodeMessage;
use super::handle_runtime_response;
pub struct CodeModeWaitHandler;
@@ -63,66 +60,21 @@ impl ToolHandler for CodeModeWaitHandler {
ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => {
let args: ExecWaitArgs = parse_arguments(&arguments)?;
let exec = ExecContext { session, turn };
let request_id = exec
.session
.services
.code_mode_service
.allocate_request_id()
.await;
let started_at = std::time::Instant::now();
let message = if args.terminate {
HostToNodeMessage::Terminate {
request_id: request_id.clone(),
cell_id: args.cell_id.clone(),
}
} else {
HostToNodeMessage::Poll {
request_id: request_id.clone(),
cell_id: args.cell_id.clone(),
yield_time_ms: args.yield_time_ms,
}
};
let process_slot = exec
let response = exec
.session
.services
.code_mode_service
.ensure_started()
.wait(codex_code_mode::WaitRequest {
cell_id: args.cell_id,
yield_time_ms: args.yield_time_ms,
terminate: args.terminate,
})
.await
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
let result = {
let mut process_slot = process_slot;
let Some(process) = process_slot.as_mut() else {
return Err(FunctionCallError::RespondToModel(format!(
"{PUBLIC_TOOL_NAME} runner failed to start"
)));
};
if !matches!(process.has_exited(), Ok(false)) {
return Err(FunctionCallError::RespondToModel(format!(
"{PUBLIC_TOOL_NAME} runner failed to start"
)));
}
let message = process
.send(&request_id, &message)
.await
.map_err(|err| err.to_string());
let message = match message {
Ok(message) => message,
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
};
handle_node_message(
&exec,
args.cell_id,
message,
Some(args.max_tokens),
started_at,
)
.map_err(FunctionCallError::RespondToModel)?;
handle_runtime_response(&exec, response, args.max_tokens, started_at)
.await
};
match result {
Ok(CodeModeSessionProgress::Finished(output))
| Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output),
Err(error) => Err(FunctionCallError::RespondToModel(error)),
}
.map_err(FunctionCallError::RespondToModel)
}
_ => Err(FunctionCallError::RespondToModel(format!(
"{WAIT_TOOL_NAME} expects JSON arguments"
-116
View File
@@ -1,116 +0,0 @@
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::warn;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
use super::call_nested_tool;
use super::process::CodeModeProcess;
use super::process::write_message;
use super::protocol::HostToNodeMessage;
use super::protocol::NodeToHostMessage;
use crate::tools::parallel::ToolCallRuntime;
pub(crate) struct CodeModeWorker {
shutdown_tx: Option<oneshot::Sender<()>>,
}
impl Drop for CodeModeWorker {
fn drop(&mut self) {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(());
}
}
}
impl CodeModeProcess {
pub(super) fn worker(
&self,
exec: ExecContext,
tool_runtime: ToolCallRuntime,
) -> CodeModeWorker {
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
let stdin = self.stdin.clone();
let message_rx = self.message_rx.clone();
tokio::spawn(async move {
loop {
let next_message = tokio::select! {
_ = &mut shutdown_rx => break,
message = async {
let mut message_rx = message_rx.lock().await;
message_rx.recv().await
} => message,
};
let Some(next_message) = next_message else {
break;
};
match next_message {
NodeToHostMessage::ToolCall { tool_call } => {
let exec = exec.clone();
let tool_runtime = tool_runtime.clone();
let stdin = stdin.clone();
tokio::spawn(async move {
let result = call_nested_tool(
exec,
tool_runtime,
tool_call.name,
tool_call.input,
CancellationToken::new(),
)
.await;
let (code_mode_result, error_text) = match result {
Ok(code_mode_result) => (code_mode_result, None),
Err(error) => (serde_json::Value::Null, Some(error.to_string())),
};
let response = HostToNodeMessage::Response {
request_id: tool_call.request_id,
id: tool_call.id,
code_mode_result,
error_text,
};
if let Err(err) = write_message(&stdin, &response).await {
warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}");
}
});
}
NodeToHostMessage::Notify { notify } => {
if notify.text.trim().is_empty() {
continue;
}
if exec
.session
.inject_response_items(vec![ResponseInputItem::CustomToolCallOutput {
call_id: notify.call_id.clone(),
name: Some(PUBLIC_TOOL_NAME.to_string()),
output: FunctionCallOutputPayload::from_text(notify.text),
}])
.await
.is_err()
{
warn!(
"failed to inject {PUBLIC_TOOL_NAME} notify message for cell {}: no active turn",
notify.cell_id
);
}
}
unexpected_message @ (NodeToHostMessage::Yielded { .. }
| NodeToHostMessage::Terminated { .. }
| NodeToHostMessage::Result { .. }) => {
error!(
"received unexpected {PUBLIC_TOOL_NAME} message in worker loop: {unexpected_message:?}"
);
break;
}
}
}
});
CodeModeWorker {
shutdown_tx: Some(shutdown_tx),
}
}
}
+24 -274
View File
@@ -1,30 +1,11 @@
use crate::client_common::tools::ToolSpec;
use crate::mcp::split_qualified_tool_name;
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
use serde_json::Value as JsonValue;
pub(crate) struct CodeModeToolReference {
pub(crate) module_path: String,
pub(crate) namespace: Vec<String>,
pub(crate) tool_key: String,
}
pub(crate) fn code_mode_tool_reference(tool_name: &str) -> CodeModeToolReference {
if let Some((server_name, tool_key)) = split_qualified_tool_name(tool_name) {
let namespace = vec!["mcp".to_string(), server_name];
return CodeModeToolReference {
module_path: format!("tools/{}.js", namespace.join("/")),
namespace,
tool_key,
};
}
CodeModeToolReference {
module_path: "tools.js".to_string(),
namespace: Vec::new(),
tool_key: tool_name.to_string(),
}
}
#[allow(unused_imports)]
#[cfg(test)]
pub(crate) use codex_code_mode::append_code_mode_sample;
#[allow(unused_imports)]
#[cfg(test)]
pub(crate) use codex_code_mode::render_json_schema_to_typescript;
pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: bool) -> ToolSpec {
if !code_mode_enabled {
@@ -33,27 +14,27 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled:
match spec {
ToolSpec::Function(mut tool) => {
if tool.name != PUBLIC_TOOL_NAME {
tool.description = append_code_mode_sample(
&tool.description,
&tool.name,
"args",
serde_json::to_value(&tool.parameters)
.ok()
.as_ref()
.map(render_json_schema_to_typescript)
.unwrap_or_else(|| "unknown".to_string()),
tool.output_schema
.as_ref()
.map(render_json_schema_to_typescript)
.unwrap_or_else(|| "unknown".to_string()),
);
}
let input_type = serde_json::to_value(&tool.parameters)
.ok()
.map(|schema| codex_code_mode::render_json_schema_to_typescript(&schema))
.unwrap_or_else(|| "unknown".to_string());
let output_type = tool
.output_schema
.as_ref()
.map(codex_code_mode::render_json_schema_to_typescript)
.unwrap_or_else(|| "unknown".to_string());
tool.description = codex_code_mode::append_code_mode_sample(
&tool.description,
&tool.name,
"args",
input_type,
output_type,
);
ToolSpec::Function(tool)
}
ToolSpec::Freeform(mut tool) => {
if tool.name != PUBLIC_TOOL_NAME {
tool.description = append_code_mode_sample(
if tool.name != codex_code_mode::PUBLIC_TOOL_NAME {
tool.description = codex_code_mode::append_code_mode_sample(
&tool.description,
&tool.name,
"input",
@@ -66,234 +47,3 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled:
other => other,
}
}
fn append_code_mode_sample(
description: &str,
tool_name: &str,
input_name: &str,
input_type: String,
output_type: String,
) -> String {
let declaration = format!(
"declare const tools: {{ {} }};",
render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type)
);
format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```")
}
fn render_code_mode_tool_declaration(
tool_name: &str,
input_name: &str,
input_type: String,
output_type: String,
) -> String {
let tool_name = normalize_code_mode_identifier(tool_name);
format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;")
}
pub(crate) fn normalize_code_mode_identifier(tool_key: &str) -> String {
let mut identifier = String::new();
for (index, ch) in tool_key.chars().enumerate() {
let is_valid = if index == 0 {
ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
} else {
ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
};
if is_valid {
identifier.push(ch);
} else {
identifier.push('_');
}
}
if identifier.is_empty() {
"_".to_string()
} else {
identifier
}
}
fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
render_json_schema_to_typescript_inner(schema)
}
fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
match schema {
JsonValue::Bool(true) => "unknown".to_string(),
JsonValue::Bool(false) => "never".to_string(),
JsonValue::Object(map) => {
if let Some(value) = map.get("const") {
return render_json_schema_literal(value);
}
if let Some(values) = map.get("enum").and_then(serde_json::Value::as_array) {
let rendered = values
.iter()
.map(render_json_schema_literal)
.collect::<Vec<_>>();
if !rendered.is_empty() {
return rendered.join(" | ");
}
}
for key in ["anyOf", "oneOf"] {
if let Some(variants) = map.get(key).and_then(serde_json::Value::as_array) {
let rendered = variants
.iter()
.map(render_json_schema_to_typescript_inner)
.collect::<Vec<_>>();
if !rendered.is_empty() {
return rendered.join(" | ");
}
}
}
if let Some(variants) = map.get("allOf").and_then(serde_json::Value::as_array) {
let rendered = variants
.iter()
.map(render_json_schema_to_typescript_inner)
.collect::<Vec<_>>();
if !rendered.is_empty() {
return rendered.join(" & ");
}
}
if let Some(schema_type) = map.get("type") {
if let Some(types) = schema_type.as_array() {
let rendered = types
.iter()
.filter_map(serde_json::Value::as_str)
.map(|schema_type| render_json_schema_type_keyword(map, schema_type))
.collect::<Vec<_>>();
if !rendered.is_empty() {
return rendered.join(" | ");
}
}
if let Some(schema_type) = schema_type.as_str() {
return render_json_schema_type_keyword(map, schema_type);
}
}
if map.contains_key("properties")
|| map.contains_key("additionalProperties")
|| map.contains_key("required")
{
return render_json_schema_object(map);
}
if map.contains_key("items") || map.contains_key("prefixItems") {
return render_json_schema_array(map);
}
"unknown".to_string()
}
_ => "unknown".to_string(),
}
}
fn render_json_schema_type_keyword(
map: &serde_json::Map<String, JsonValue>,
schema_type: &str,
) -> String {
match schema_type {
"string" => "string".to_string(),
"number" | "integer" => "number".to_string(),
"boolean" => "boolean".to_string(),
"null" => "null".to_string(),
"array" => render_json_schema_array(map),
"object" => render_json_schema_object(map),
_ => "unknown".to_string(),
}
}
fn render_json_schema_array(map: &serde_json::Map<String, JsonValue>) -> String {
if let Some(items) = map.get("items") {
let item_type = render_json_schema_to_typescript_inner(items);
return format!("Array<{item_type}>");
}
if let Some(items) = map.get("prefixItems").and_then(serde_json::Value::as_array) {
let item_types = items
.iter()
.map(render_json_schema_to_typescript_inner)
.collect::<Vec<_>>();
if !item_types.is_empty() {
return format!("[{}]", item_types.join(", "));
}
}
"unknown[]".to_string()
}
fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String {
let required = map
.get("required")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let properties = map
.get("properties")
.and_then(serde_json::Value::as_object)
.cloned()
.unwrap_or_default();
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
let mut lines = sorted_properties
.into_iter()
.map(|(name, value)| {
let optional = if required.iter().any(|required_name| required_name == name) {
""
} else {
"?"
};
let property_name = render_json_schema_property_name(name);
let property_type = render_json_schema_to_typescript_inner(value);
format!("{property_name}{optional}: {property_type};")
})
.collect::<Vec<_>>();
if let Some(additional_properties) = map.get("additionalProperties") {
let additional_type = match additional_properties {
JsonValue::Bool(true) => Some("unknown".to_string()),
JsonValue::Bool(false) => None,
value => Some(render_json_schema_to_typescript_inner(value)),
};
if let Some(additional_type) = additional_type {
lines.push(format!("[key: string]: {additional_type};"));
}
} else if properties.is_empty() {
lines.push("[key: string]: unknown;".to_string());
}
if lines.is_empty() {
return "{}".to_string();
}
format!("{{ {} }}", lines.join(" "))
}
fn render_json_schema_property_name(name: &str) -> String {
if normalize_code_mode_identifier(name) == name {
name.to_string()
} else {
serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\"")))
}
}
fn render_json_schema_literal(value: &JsonValue) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
}
#[cfg(test)]
#[path = "code_mode_description_tests.rs"]
mod tests;
@@ -6,6 +6,8 @@
//! then optionally layer role-specific config on top.
use crate::agent::AgentStatus;
use crate::agent::agent_resolver::resolve_agent_target;
use crate::agent::agent_resolver::resolve_agent_targets;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::codex::Session;
use crate::codex::TurnContext;
@@ -22,6 +24,7 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use async_trait::async_trait;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ResponseInputItem;
@@ -59,11 +62,6 @@ pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000;
#[derive(Debug, Deserialize)]
struct CloseAgentArgs {
id: String,
}
fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
match payload {
ToolPayload::Function { arguments } => Ok(arguments),
@@ -111,11 +109,6 @@ mod send_input;
mod spawn;
pub(crate) mod wait;
fn agent_id(id: &str) -> Result<ThreadId, FunctionCallError> {
ThreadId::from_string(id)
.map_err(|e| FunctionCallError::RespondToModel(format!("invalid agent id {id}: {e:?}")))
}
fn build_wait_agent_statuses(
statuses: &HashMap<ThreadId, AgentStatus>,
receiver_agents: &[CollabAgentRef],
@@ -155,9 +148,10 @@ fn build_wait_agent_statuses(
fn collab_spawn_error(err: CodexErr) -> FunctionCallError {
match err {
CodexErr::UnsupportedOperation(_) => {
CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => {
FunctionCallError::RespondToModel("collab manager unavailable".to_string())
}
CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message),
err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")),
}
}
@@ -179,15 +173,28 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError {
fn thread_spawn_source(
parent_thread_id: ThreadId,
parent_session_source: &SessionSource,
depth: i32,
agent_role: Option<&str>,
) -> SessionSource {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
task_name: Option<String>,
) -> Result<SessionSource, FunctionCallError> {
let agent_path = task_name
.as_deref()
.map(|task_name| {
parent_session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root)
.join(task_name)
.map_err(FunctionCallError::RespondToModel)
})
.transpose()?;
Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_path,
agent_nickname: None,
agent_role: agent_role.map(str::to_string),
})
}))
}
fn parse_collab_input(
@@ -24,13 +24,12 @@ impl ToolHandler for Handler {
} = invocation;
let arguments = function_arguments(payload)?;
let args: CloseAgentArgs = parse_arguments(&arguments)?;
let agent_id = agent_id(&args.id)?;
let (receiver_agent_nickname, receiver_agent_role) = session
let agent_id = resolve_agent_target(&session, &turn, &args.target).await?;
let receiver_agent = session
.services
.agent_control
.get_agent_nickname_and_role(agent_id)
.await
.unwrap_or((None, None));
.get_agent_metadata(agent_id)
.unwrap_or_default();
session
.send_event(
&turn,
@@ -58,8 +57,8 @@ impl ToolHandler for Handler {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id: agent_id,
receiver_agent_nickname: receiver_agent_nickname.clone(),
receiver_agent_role: receiver_agent_role.clone(),
receiver_agent_nickname: receiver_agent.agent_nickname.clone(),
receiver_agent_role: receiver_agent.agent_role.clone(),
status,
}
.into(),
@@ -82,8 +81,8 @@ impl ToolHandler for Handler {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id: agent_id,
receiver_agent_nickname,
receiver_agent_role,
receiver_agent_nickname: receiver_agent.agent_nickname,
receiver_agent_role: receiver_agent.agent_role,
status: status.clone(),
}
.into(),
@@ -119,3 +118,8 @@ impl ToolOutput for CloseAgentResult {
tool_output_code_mode_result(self, "close_agent")
}
}
#[derive(Debug, Deserialize)]
struct CloseAgentArgs {
target: String,
}
@@ -25,13 +25,14 @@ impl ToolHandler for Handler {
} = invocation;
let arguments = function_arguments(payload)?;
let args: ResumeAgentArgs = parse_arguments(&arguments)?;
let receiver_thread_id = agent_id(&args.id)?;
let (receiver_agent_nickname, receiver_agent_role) = session
let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| {
FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id))
})?;
let receiver_agent = session
.services
.agent_control
.get_agent_nickname_and_role(receiver_thread_id)
.await
.unwrap_or((None, None));
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
let child_depth = next_thread_spawn_depth(&turn.session_source);
let max_depth = turn.config.agent_max_depth;
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
@@ -47,8 +48,8 @@ impl ToolHandler for Handler {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id,
receiver_agent_nickname: receiver_agent_nickname.clone(),
receiver_agent_role: receiver_agent_role.clone(),
receiver_agent_nickname: receiver_agent.agent_nickname.clone(),
receiver_agent_role: receiver_agent.agent_role.clone(),
}
.into(),
)
@@ -59,11 +60,22 @@ impl ToolHandler for Handler {
.agent_control
.get_status(receiver_thread_id)
.await;
let error = if matches!(status, AgentStatus::NotFound) {
let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) {
match try_resume_closed_agent(&session, &turn, receiver_thread_id, child_depth).await {
Ok(resumed_status) => {
status = resumed_status;
None
Ok(()) => {
status = session
.services
.agent_control
.get_status(receiver_thread_id)
.await;
(
session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or(receiver_agent),
None,
)
}
Err(err) => {
status = session
@@ -71,19 +83,12 @@ impl ToolHandler for Handler {
.agent_control
.get_status(receiver_thread_id)
.await;
Some(err)
(receiver_agent, Some(err))
}
}
} else {
None
(receiver_agent, None)
};
let (receiver_agent_nickname, receiver_agent_role) = session
.services
.agent_control
.get_agent_nickname_and_role(receiver_thread_id)
.await
.unwrap_or((receiver_agent_nickname, receiver_agent_role));
session
.send_event(
&turn,
@@ -91,8 +96,8 @@ impl ToolHandler for Handler {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id,
receiver_agent_nickname,
receiver_agent_role,
receiver_agent_nickname: receiver_agent.agent_nickname,
receiver_agent_role: receiver_agent.agent_role,
status: status.clone(),
}
.into(),
@@ -142,9 +147,9 @@ async fn try_resume_closed_agent(
turn: &Arc<TurnContext>,
receiver_thread_id: ThreadId,
child_depth: i32,
) -> Result<AgentStatus, FunctionCallError> {
) -> Result<(), FunctionCallError> {
let config = build_agent_resume_config(turn.as_ref(), child_depth)?;
let resumed_thread_id = session
session
.services
.agent_control
.resume_agent_from_rollout(
@@ -152,16 +157,13 @@ async fn try_resume_closed_agent(
receiver_thread_id,
thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
/*agent_role*/ None,
),
/*task_name*/ None,
)?,
)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
Ok(session
.services
.agent_control
.get_status(resumed_thread_id)
.await)
.map(|_| ())
.map_err(|err| collab_agent_error(receiver_thread_id, err))
}
@@ -1,7 +1,15 @@
use super::*;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
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;
@@ -24,15 +32,14 @@ impl ToolHandler for Handler {
} = invocation;
let arguments = function_arguments(payload)?;
let args: SendInputArgs = parse_arguments(&arguments)?;
let receiver_thread_id = agent_id(&args.id)?;
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_nickname, receiver_agent_role) = session
let receiver_agent = session
.services
.agent_control
.get_agent_nickname_and_role(receiver_thread_id)
.await
.unwrap_or((None, None));
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
if args.interrupt {
session
.services
@@ -53,12 +60,40 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let result = session
.services
.agent_control
.send_input(receiver_thread_id, input_items)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err));
let agent_control = session.services.agent_control.clone();
let result = if turn.config.features.enabled(Feature::MultiAgentV2)
&& 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 instruction = InterAgentInstruction::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
);
agent_control
.deliver_inter_agent_instruction(
receiver_thread_id,
instruction,
if args.interrupt {
InterAgentDelivery::NextTurn
} else {
InterAgentDelivery::CurrentTurn
},
)
.await
} else {
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
@@ -71,8 +106,8 @@ impl ToolHandler for Handler {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id,
receiver_agent_nickname,
receiver_agent_role,
receiver_agent_nickname: receiver_agent.agent_nickname,
receiver_agent_role: receiver_agent.agent_role,
prompt,
status,
}
@@ -87,7 +122,7 @@ impl ToolHandler for Handler {
#[derive(Debug, Deserialize)]
struct SendInputArgs {
id: String,
target: String,
message: Option<String>,
items: Option<Vec<UserInput>>,
#[serde(default)]
@@ -77,26 +77,29 @@ impl ToolHandler for Handler {
let result = session
.services
.agent_control
.spawn_agent_with_options(
.spawn_agent_with_metadata(
config,
input_items,
Some(thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
)),
args.task_name.clone(),
)?),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
},
)
.await
.map_err(collab_spawn_error);
let (new_thread_id, status) = match &result {
Ok(thread_id) => (
Some(*thread_id),
session.services.agent_control.get_status(*thread_id).await,
let (new_thread_id, new_agent_metadata, status) = match &result {
Ok(spawned_agent) => (
Some(spawned_agent.thread_id),
Some(spawned_agent.metadata.clone()),
spawned_agent.status.clone(),
),
Err(_) => (None, AgentStatus::NotFound),
Err(_) => (None, None, AgentStatus::NotFound),
};
let agent_snapshot = match new_thread_id {
Some(thread_id) => {
@@ -108,19 +111,20 @@ impl ToolHandler for Handler {
}
None => None,
};
let (new_agent_nickname, new_agent_role) = match (&agent_snapshot, new_thread_id) {
(Some(snapshot), _) => (
snapshot.session_source.get_nickname(),
snapshot.session_source.get_agent_role(),
),
(None, Some(thread_id)) => session
.services
.agent_control
.get_agent_nickname_and_role(thread_id)
.await
.unwrap_or((None, None)),
(None, None) => (None, None),
};
let (new_agent_path, new_agent_nickname, new_agent_role) =
match (&agent_snapshot, new_agent_metadata) {
(Some(snapshot), _) => (
snapshot.session_source.get_agent_path().map(String::from),
snapshot.session_source.get_nickname(),
snapshot.session_source.get_agent_role(),
),
(None, Some(metadata)) => (
metadata.agent_path.map(String::from),
metadata.agent_nickname,
metadata.agent_role,
),
(None, None) => (None, None, None),
};
let effective_model = agent_snapshot
.as_ref()
.map(|snapshot| snapshot.model.clone())
@@ -130,6 +134,7 @@ impl ToolHandler for Handler {
.and_then(|snapshot| snapshot.reasoning_effort)
.unwrap_or(args.reasoning_effort.unwrap_or_default());
let nickname = new_agent_nickname.clone();
let task_name = new_agent_path.clone();
session
.send_event(
&turn,
@@ -147,7 +152,7 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let new_thread_id = result?;
let new_thread_id = result?.thread_id;
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
turn.session_telemetry.counter(
"codex.multi_agent.spawn",
@@ -156,7 +161,8 @@ impl ToolHandler for Handler {
);
Ok(SpawnAgentResult {
agent_id: new_thread_id.to_string(),
agent_id: task_name.is_none().then(|| new_thread_id.to_string()),
task_name,
nickname,
})
}
@@ -166,6 +172,7 @@ impl ToolHandler for Handler {
struct SpawnAgentArgs {
message: Option<String>,
items: Option<Vec<UserInput>>,
task_name: Option<String>,
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
@@ -175,7 +182,8 @@ struct SpawnAgentArgs {
#[derive(Debug, Serialize)]
pub(crate) struct SpawnAgentResult {
agent_id: String,
agent_id: Option<String>,
task_name: Option<String>,
nickname: Option<String>,
}
@@ -34,28 +34,27 @@ impl ToolHandler for Handler {
} = invocation;
let arguments = function_arguments(payload)?;
let args: WaitArgs = parse_arguments(&arguments)?;
if args.ids.is_empty() {
return Err(FunctionCallError::RespondToModel(
"ids must be non-empty".to_owned(),
));
}
let receiver_thread_ids = args
.ids
.iter()
.map(|id| agent_id(id))
.collect::<Result<Vec<_>, _>>()?;
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_nickname, agent_role) = session
let agent_metadata = session
.services
.agent_control
.get_agent_nickname_and_role(*receiver_thread_id)
.await
.unwrap_or((None, None));
.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_role,
agent_nickname: agent_metadata.agent_nickname,
agent_role: agent_metadata.agent_role,
});
}
@@ -151,11 +150,20 @@ impl ToolHandler for Handler {
results
};
let statuses_map = statuses.clone().into_iter().collect::<HashMap<_, _>>();
let agent_statuses = build_wait_agent_statuses(&statuses_map, &receiver_agents);
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_map.clone(),
timed_out: statuses.is_empty(),
status: statuses
.into_iter()
.filter_map(|(thread_id, status)| {
target_by_thread_id
.get(&thread_id)
.cloned()
.map(|target| (target, status))
})
.collect(),
timed_out,
};
session
@@ -165,7 +173,7 @@ impl ToolHandler for Handler {
sender_thread_id: session.conversation_id,
call_id,
agent_statuses,
statuses: statuses_map,
statuses: statuses_by_id,
}
.into(),
)
@@ -177,13 +185,14 @@ impl ToolHandler for Handler {
#[derive(Debug, Deserialize)]
struct WaitArgs {
ids: Vec<String>,
#[serde(default)]
targets: Vec<String>,
timeout_ms: Option<i64>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct WaitAgentResult {
pub(crate) status: HashMap<ThreadId, AgentStatus>,
pub(crate) status: HashMap<String, AgentStatus>,
pub(crate) timed_out: bool,
}
@@ -7,6 +7,7 @@ use crate::codex::make_session_and_context;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
use crate::config::types::ShellEnvironmentPolicy;
use crate::function_tool::FunctionCallError;
use crate::protocol::AgentStatus;
use crate::protocol::AskForApproval;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
@@ -14,6 +15,9 @@ use crate::protocol::Op;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionSource;
use crate::protocol::SubAgentSource;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::context::ToolOutput;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_features::Feature;
@@ -24,6 +28,7 @@ use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::user_input::UserInput;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde_json::json;
@@ -33,6 +38,7 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
fn invocation(
session: Arc<crate::codex::Session>,
@@ -57,6 +63,10 @@ fn function_payload(args: serde_json::Value) -> ToolPayload {
}
}
fn parse_agent_id(id: &str) -> ThreadId {
ThreadId::from_string(id).expect("agent id should be valid")
}
fn thread_manager() -> ThreadManager {
ThreadManager::with_models_provider_for_tests(
CodexAuth::from_api_key("dummy"),
@@ -64,6 +74,31 @@ fn thread_manager() -> ThreadManager {
)
}
#[derive(Clone, Copy)]
struct NeverEndingTask;
#[async_trait::async_trait]
impl SessionTask for NeverEndingTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
fn span_name(&self) -> &'static str {
"session_task.multi_agent_never_ending"
}
async fn run(
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
cancellation_token.cancelled().await;
None
}
}
fn expect_text_output<T>(output: T) -> (String, Option<bool>)
where
T: ToolOutput,
@@ -195,7 +230,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
let (content, _) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid");
let agent_id = parse_agent_id(&result.agent_id);
assert!(
result
.nickname
@@ -212,6 +247,33 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
assert_eq!(snapshot.model_provider_id, "ollama");
}
#[tokio::test]
async fn spawn_agent_includes_task_name_key_when_not_named() {
let (mut session, turn) = make_session_and_context().await;
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let output = SpawnAgentHandler
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo"
})),
))
.await
.expect("spawn_agent should succeed");
let (content, success) = expect_text_output(output);
let result: serde_json::Value =
serde_json::from_str(&content).expect("spawn_agent result should be json");
assert!(result["agent_id"].is_string());
assert_eq!(result["task_name"], serde_json::Value::Null);
assert!(result.get("nickname").is_some());
assert_eq!(success, Some(true));
}
#[tokio::test]
async fn spawn_agent_errors_when_manager_dropped() {
let (session, turn) = make_session_and_context().await;
@@ -230,6 +292,461 @@ async fn spawn_agent_errors_when_manager_dropped() {
);
}
#[tokio::test]
async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
nickname: Option<String>,
}
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();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let spawn_output = SpawnAgentHandler
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "test_process"
})),
))
.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");
assert_eq!(spawn_result.task_name, "/root/test_process");
assert!(spawn_result.nickname.is_some());
let child_thread_id = session
.services
.agent_control
.resolve_agent_reference(
session.conversation_id,
&turn.session_source,
"test_process",
)
.await
.expect("relative path should resolve");
let child_snapshot = manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist")
.config_snapshot()
.await;
assert_eq!(
child_snapshot.session_source.get_agent_path().as_deref(),
Some("/root/test_process")
);
SendInputHandler
.handle(invocation(
session.clone(),
turn.clone(),
"send_input",
function_payload(json!({
"target": "test_process",
"message": "continue"
})),
))
.await
.expect("send_input should accept v2 path");
let child_thread = manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
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
== "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue"
))
)
});
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() {
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);
SpawnAgentHandler
.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 invocation = invocation(
session,
turn,
"send_input",
function_payload(json!({
"target": agent_id.to_string(),
"items": [
{"type": "mention", "name": "drive", "path": "app://google_drive"},
{"type": "text", "text": "read the folder"}
]
})),
);
SendInputHandler
.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 captured = manager
.captured_ops()
.into_iter()
.find(|(id, op)| *id == agent_id && *op == expected);
assert_eq!(captured, Some((agent_id, expected)));
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
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder"
))
)
});
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() {
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);
SpawnAgentHandler
.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;
SendInputHandler
.handle(invocation(
session,
turn,
"send_input",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"interrupt": true
})),
))
.await
.expect("interrupting v2 send_input 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::UserInput { items, .. }
if items.iter().any(|item| matches!(
item,
UserInput::Text { text, .. } if text == "continue"
))
)));
timeout(Duration::from_secs(5), async {
loop {
let history_items = thread
.codex
.session
.clone_history()
.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
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
))
)
});
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 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("interrupting v2 send_input should preserve the redirected message");
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() {
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();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let output = SpawnAgentHandler
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "test_process"
})),
))
.await
.expect("spawn_agent should succeed");
let (content, success) = expect_text_output(output);
let result: serde_json::Value =
serde_json::from_str(&content).expect("spawn_agent result should be json");
assert_eq!(result["agent_id"], serde_json::Value::Null);
assert_eq!(result["task_name"], "/root/test_process");
assert!(result.get("nickname").is_some());
assert_eq!(success, Some(true));
}
#[tokio::test]
async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() {
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();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let invocation = invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "BadName"
})),
);
let Err(err) = SpawnAgentHandler.handle(invocation).await else {
panic!("invalid agent name should be rejected");
};
assert_eq!(
err,
FunctionCallError::RespondToModel(
"agent_name must use only lowercase letters, digits, and underscores".to_string()
)
);
}
#[tokio::test]
async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() {
fn pick_allowed_sandbox_policy(
@@ -293,7 +810,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() {
let (content, _) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid");
let agent_id = parse_agent_id(&result.agent_id);
assert!(
result
.nickname
@@ -334,6 +851,7 @@ async fn spawn_agent_rejects_when_depth_limit_exceeded() {
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: max_depth,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
@@ -373,6 +891,7 @@ async fn spawn_agent_allows_depth_up_to_configured_max_depth() {
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: DEFAULT_AGENT_MAX_DEPTH,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
@@ -407,7 +926,7 @@ async fn send_input_rejects_empty_message() {
Arc::new(session),
Arc::new(turn),
"send_input",
function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})),
function_payload(json!({"target": ThreadId::new().to_string(), "message": ""})),
);
let Err(err) = SendInputHandler.handle(invocation).await else {
panic!("empty message should be rejected");
@@ -426,7 +945,7 @@ async fn send_input_rejects_when_message_and_items_are_both_set() {
Arc::new(turn),
"send_input",
function_payload(json!({
"id": ThreadId::new().to_string(),
"target": ThreadId::new().to_string(),
"message": "hello",
"items": [{"type": "mention", "name": "drive", "path": "app://drive"}]
})),
@@ -449,7 +968,7 @@ async fn send_input_rejects_invalid_id() {
Arc::new(session),
Arc::new(turn),
"send_input",
function_payload(json!({"id": "not-a-uuid", "message": "hi"})),
function_payload(json!({"target": "not-a-uuid", "message": "hi"})),
);
let Err(err) = SendInputHandler.handle(invocation).await else {
panic!("invalid id should be rejected");
@@ -457,7 +976,10 @@ async fn send_input_rejects_invalid_id() {
let FunctionCallError::RespondToModel(msg) = err else {
panic!("expected respond-to-model error");
};
assert!(msg.starts_with("invalid agent id not-a-uuid:"));
assert_eq!(
msg,
"agent_name must use only lowercase letters, digits, and underscores"
);
}
#[tokio::test]
@@ -470,7 +992,7 @@ async fn send_input_reports_missing_agent() {
Arc::new(session),
Arc::new(turn),
"send_input",
function_payload(json!({"id": agent_id.to_string(), "message": "hi"})),
function_payload(json!({"target": agent_id.to_string(), "message": "hi"})),
);
let Err(err) = SendInputHandler.handle(invocation).await else {
panic!("missing agent should be reported");
@@ -494,7 +1016,7 @@ async fn send_input_interrupts_before_prompt() {
Arc::new(turn),
"send_input",
function_payload(json!({
"id": agent_id.to_string(),
"target": agent_id.to_string(),
"message": "hi",
"interrupt": true
})),
@@ -533,7 +1055,7 @@ async fn send_input_accepts_structured_items() {
Arc::new(turn),
"send_input",
function_payload(json!({
"id": agent_id.to_string(),
"target": agent_id.to_string(),
"items": [
{"type": "mention", "name": "drive", "path": "app://google_drive"},
{"type": "text", "text": "read the folder"}
@@ -704,7 +1226,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() {
session,
turn,
"send_input",
function_payload(json!({"id": agent_id.to_string(), "message": "hello"})),
function_payload(json!({"target": agent_id.to_string(), "message": "hello"})),
);
let output = SendInputHandler
.handle(send_invocation)
@@ -737,6 +1259,7 @@ async fn resume_agent_rejects_when_depth_limit_exceeded() {
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: max_depth,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
@@ -766,7 +1289,7 @@ async fn wait_agent_rejects_non_positive_timeout() {
Arc::new(turn),
"wait_agent",
function_payload(json!({
"ids": [ThreadId::new().to_string()],
"targets": [ThreadId::new().to_string()],
"timeout_ms": 0
})),
);
@@ -780,13 +1303,13 @@ async fn wait_agent_rejects_non_positive_timeout() {
}
#[tokio::test]
async fn wait_agent_rejects_invalid_id() {
async fn wait_agent_rejects_invalid_target() {
let (session, turn) = make_session_and_context().await;
let invocation = invocation(
Arc::new(session),
Arc::new(turn),
"wait_agent",
function_payload(json!({"ids": ["invalid"]})),
function_payload(json!({"targets": ["invalid"]})),
);
let Err(err) = WaitAgentHandler.handle(invocation).await else {
panic!("invalid id should be rejected");
@@ -794,27 +1317,62 @@ async fn wait_agent_rejects_invalid_id() {
let FunctionCallError::RespondToModel(msg) = err else {
panic!("expected respond-to-model error");
};
assert!(msg.starts_with("invalid agent id invalid:"));
assert_eq!(msg, "live agent path `/root/invalid` not found");
}
#[tokio::test]
async fn wait_agent_rejects_empty_ids() {
async fn wait_agent_rejects_empty_targets() {
let (session, turn) = make_session_and_context().await;
let invocation = invocation(
Arc::new(session),
Arc::new(turn),
"wait_agent",
function_payload(json!({"ids": []})),
function_payload(json!({"targets": []})),
);
let Err(err) = WaitAgentHandler.handle(invocation).await else {
panic!("empty ids should be rejected");
};
assert_eq!(
err,
FunctionCallError::RespondToModel("ids must be non-empty".to_string())
FunctionCallError::RespondToModel("agent targets must be non-empty".to_string())
);
}
#[tokio::test]
async fn multi_agent_v2_wait_agent_accepts_targets_argument() {
let (mut session, mut turn) = make_session_and_context().await;
let target = ThreadId::new().to_string();
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);
let invocation = invocation(
Arc::new(session),
Arc::new(turn),
"wait_agent",
function_payload(json!({"targets": [target.clone()]})),
);
let output = WaitAgentHandler
.handle(invocation)
.await
.expect("targets should be accepted in v2 mode");
let (content, success) = expect_text_output(output);
let result: wait::WaitAgentResult =
serde_json::from_str(&content).expect("wait_agent result should be json");
assert_eq!(
result,
wait::WaitAgentResult {
status: HashMap::from([(target, AgentStatus::NotFound)]),
timed_out: false,
}
);
assert_eq!(success, None);
}
#[tokio::test]
async fn wait_agent_returns_not_found_for_missing_agents() {
let (mut session, turn) = make_session_and_context().await;
@@ -827,7 +1385,7 @@ async fn wait_agent_returns_not_found_for_missing_agents() {
Arc::new(turn),
"wait_agent",
function_payload(json!({
"ids": [id_a.to_string(), id_b.to_string()],
"targets": [id_a.to_string(), id_b.to_string()],
"timeout_ms": 1000
})),
);
@@ -841,7 +1399,10 @@ async fn wait_agent_returns_not_found_for_missing_agents() {
assert_eq!(
result,
wait::WaitAgentResult {
status: HashMap::from([(id_a, AgentStatus::NotFound), (id_b, AgentStatus::NotFound),]),
status: HashMap::from([
(id_a.to_string(), AgentStatus::NotFound),
(id_b.to_string(), AgentStatus::NotFound),
]),
timed_out: false
}
);
@@ -861,7 +1422,7 @@ async fn wait_agent_times_out_when_status_is_not_final() {
Arc::new(turn),
"wait_agent",
function_payload(json!({
"ids": [agent_id.to_string()],
"targets": [agent_id.to_string()],
"timeout_ms": MIN_WAIT_TIMEOUT_MS
})),
);
@@ -901,7 +1462,7 @@ async fn wait_agent_clamps_short_timeouts_to_minimum() {
Arc::new(turn),
"wait_agent",
function_payload(json!({
"ids": [agent_id.to_string()],
"targets": [agent_id.to_string()],
"timeout_ms": 10
})),
);
@@ -951,7 +1512,7 @@ async fn wait_agent_returns_final_status_without_timeout() {
Arc::new(turn),
"wait_agent",
function_payload(json!({
"ids": [agent_id.to_string()],
"targets": [agent_id.to_string()],
"timeout_ms": 1000
})),
);
@@ -965,13 +1526,106 @@ async fn wait_agent_returns_final_status_without_timeout() {
assert_eq!(
result,
wait::WaitAgentResult {
status: HashMap::from([(agent_id, AgentStatus::Shutdown)]),
status: HashMap::from([(agent_id.to_string(), AgentStatus::Shutdown)]),
timed_out: false
}
);
assert_eq!(success, None);
}
#[tokio::test]
async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
}
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();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let spawn_output = SpawnAgentHandler
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "test_process"
})),
))
.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 agent_id = session
.services
.agent_control
.resolve_agent_reference(
session.conversation_id,
&turn.session_source,
"test_process",
)
.await
.expect("relative path should resolve");
let mut status_rx = manager
.agent_control()
.subscribe_status(agent_id)
.await
.expect("subscribe should succeed");
let child_thread = manager
.get_thread(agent_id)
.await
.expect("child should exist");
let _ = child_thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
let _ = timeout(Duration::from_secs(1), status_rx.changed())
.await
.expect("shutdown status should arrive");
let wait_output = WaitAgentHandler
.handle(invocation(
session,
turn,
"wait_agent",
function_payload(json!({
"targets": ["test_process"],
"timeout_ms": 1000
})),
))
.await
.expect("wait_agent should succeed");
let (content, success) = expect_text_output(wait_output);
let result: wait::WaitAgentResult =
serde_json::from_str(&content).expect("wait_agent result should be json");
assert_eq!(
result,
wait::WaitAgentResult {
status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]),
timed_out: false,
}
);
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;
@@ -986,7 +1640,7 @@ async fn close_agent_submits_shutdown_and_returns_previous_status() {
Arc::new(session),
Arc::new(turn),
"close_agent",
function_payload(json!({"id": agent_id.to_string()})),
function_payload(json!({"target": agent_id.to_string()})),
);
let output = CloseAgentHandler
.handle(invocation)
@@ -1038,13 +1692,12 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
let (child_content, child_success) = expect_text_output(child_spawn_output);
let child_result: serde_json::Value =
serde_json::from_str(&child_content).expect("child spawn result should be json");
let child_thread_id = agent_id(
let child_thread_id = parse_agent_id(
child_result
.get("agent_id")
.and_then(serde_json::Value::as_str)
.expect("child spawn result should include agent_id"),
)
.expect("child agent_id should be valid");
);
assert_eq!(child_success, Some(true));
let child_thread = manager
@@ -1064,13 +1717,12 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
let (grandchild_content, grandchild_success) = expect_text_output(grandchild_spawn_output);
let grandchild_result: serde_json::Value =
serde_json::from_str(&grandchild_content).expect("grandchild spawn result should be json");
let grandchild_thread_id = agent_id(
let grandchild_thread_id = parse_agent_id(
grandchild_result
.get("agent_id")
.and_then(serde_json::Value::as_str)
.expect("grandchild spawn result should include agent_id"),
)
.expect("grandchild agent_id should be valid");
);
assert_eq!(grandchild_success, Some(true));
let close_output = CloseAgentHandler
@@ -1078,7 +1730,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
parent_session.clone(),
parent_session.new_default_turn().await,
"close_agent",
function_payload(json!({"id": child_thread_id.to_string()})),
function_payload(json!({"target": child_thread_id.to_string()})),
))
.await
.expect("close_agent should close the child subtree");
@@ -1130,7 +1782,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
parent_session.clone(),
parent_session.new_default_turn().await,
"close_agent",
function_payload(json!({"id": child_thread_id.to_string()})),
function_payload(json!({"target": child_thread_id.to_string()})),
))
.await
.expect("close_agent should be repeatable for the child subtree");
+1 -2
View File
@@ -4,7 +4,6 @@ use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::sandboxing::SandboxPermissions;
use crate::tools::code_mode::is_code_mode_nested_tool;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -67,7 +66,7 @@ impl ToolRouter {
specs
.iter()
.filter_map(|configured_tool| {
if !is_code_mode_nested_tool(configured_tool.spec.name()) {
if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) {
Some(configured_tool.spec.clone())
} else {
None
+1 -4
View File
@@ -33,7 +33,6 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use codex_features::Feature;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
@@ -227,9 +226,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
&req.cwd,
&req.explicit_env_overrides,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell)
&& ctx.session.features().enabled(Feature::PowershellUtf8)
{
let command = if matches!(session_shell.shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {
command
@@ -36,7 +36,6 @@ use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_features::Feature;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
@@ -200,9 +199,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&req.cwd,
&req.explicit_env_overrides,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell)
&& ctx.session.features().enabled(Feature::PowershellUtf8)
{
let command = if matches!(session_shell.shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {
command
+58 -30
View File
@@ -11,9 +11,6 @@ use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
use crate::tools::code_mode::WAIT_TOOL_NAME;
use crate::tools::code_mode::is_code_mode_nested_tool;
use crate::tools::code_mode::tool_description as code_mode_tool_description;
use crate::tools::code_mode::wait_tool_description as code_mode_wait_tool_description;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::DiscoverableTool;
@@ -129,20 +126,29 @@ fn agent_status_output_schema() -> JsonValue {
})
}
fn spawn_agent_output_schema() -> JsonValue {
fn spawn_agent_output_schema(multi_agent_v2: bool) -> JsonValue {
let task_name_description = if multi_agent_v2 {
"Canonical task name for the spawned agent."
} else {
"Canonical task name for the spawned agent when one was assigned."
};
json!({
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Thread identifier for the spawned agent."
"type": ["string", "null"],
"description": "Thread identifier for the spawned agent when no task name was assigned."
},
"task_name": {
"type": ["string", "null"],
"description": task_name_description
},
"nickname": {
"type": ["string", "null"],
"description": "User-facing nickname for the spawned agent when available."
}
},
"required": ["agent_id", "nickname"],
"required": ["agent_id", "task_name", "nickname"],
"additionalProperties": false
})
}
@@ -178,7 +184,7 @@ fn wait_output_schema() -> JsonValue {
"properties": {
"status": {
"type": "object",
"description": "Final statuses keyed by agent id for agents that finished before the timeout.",
"description": "Final statuses keyed by canonical task name when available, otherwise by agent id.",
"additionalProperties": agent_status_output_schema()
},
"timed_out": {
@@ -276,6 +282,7 @@ pub(crate) struct ToolsConfig {
pub js_repl_tools_only: bool,
pub can_request_original_image_detail: bool,
pub collab_tools: bool,
pub multi_agent_v2: bool,
pub artifact_tools: bool,
pub request_user_input: bool,
pub default_mode_request_user_input: bool,
@@ -325,6 +332,7 @@ impl ToolsConfig {
let include_js_repl_tools_only =
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
let include_collab_tools = features.enabled(Feature::Collab);
let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2);
let include_agent_jobs = features.enabled(Feature::SpawnCsv);
let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_));
let include_default_mode_request_user_input =
@@ -408,6 +416,7 @@ impl ToolsConfig {
js_repl_tools_only: include_js_repl_tools_only,
can_request_original_image_detail: include_original_image_detail,
collab_tools: include_collab_tools,
multi_agent_v2: include_multi_agent_v2,
artifact_tools: include_artifact_tools,
request_user_input: include_request_user_input,
default_mode_request_user_input: include_default_mode_request_user_input,
@@ -821,7 +830,7 @@ fn create_wait_tool() -> ToolSpec {
name: WAIT_TOOL_NAME.to_string(),
description: format!(
"Waits on a yielded `{PUBLIC_TOOL_NAME}` cell and returns new output or completion.\n{}",
code_mode_wait_tool_description().trim()
codex_code_mode::build_wait_tool_description().trim()
),
strict: false,
parameters: JsonSchema::Object {
@@ -1076,7 +1085,8 @@ fn create_collab_input_items_schema() -> JsonSchema {
fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
let available_models_description = spawn_agent_models_description(&config.available_models);
let properties = BTreeMap::from([
let return_value_description = "Returns the canonical task name when the spawned agent was named, otherwise the agent id, plus the user-facing nickname when available.";
let mut properties = BTreeMap::from([
(
"message".to_string(),
JsonSchema::String {
@@ -1123,6 +1133,15 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
},
),
]);
properties.insert(
"task_name".to_string(),
JsonSchema::String {
description: Some(
"Optional task name for the new agent. Use lowercase letters, digits, and underscores."
.to_string(),
),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
@@ -1131,7 +1150,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work.
Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.
Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself.
Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool.
Spawn a sub-agent for a well-scoped task. {return_value_description} This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool.
{available_models_description}
### When to delegate vs. do the subtask yourself
@@ -1170,7 +1189,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
required: None,
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema()),
output_schema: Some(spawn_agent_output_schema(config.multi_agent_v2)),
})
}
@@ -1335,9 +1354,11 @@ fn create_report_agent_job_result_tool() -> ToolSpec {
fn create_send_input_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"id".to_string(),
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to message (from spawn_agent).".to_string()),
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
(
@@ -1369,7 +1390,7 @@ fn create_send_input_tool() -> ToolSpec {
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
@@ -1404,11 +1425,11 @@ fn create_resume_agent_tool() -> ToolSpec {
fn create_wait_agent_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"ids".to_string(),
"targets".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Agent ids to wait on. Pass multiple ids to wait for whichever finishes first."
"Agent ids or canonical task names to wait on. Pass multiple targets to wait for whichever finishes first."
.to_string(),
),
},
@@ -1430,7 +1451,7 @@ fn create_wait_agent_tool() -> ToolSpec {
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["ids".to_string()]),
required: Some(vec!["targets".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(wait_output_schema()),
@@ -1556,9 +1577,11 @@ fn create_request_permissions_tool() -> ToolSpec {
fn create_close_agent_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"id".to_string(),
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to close (from spawn_agent).".to_string()),
description: Some(
"Agent id or canonical task name to close (from spawn_agent).".to_string(),
),
},
);
@@ -1569,7 +1592,7 @@ fn create_close_agent_tool() -> ToolSpec {
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(close_agent_output_schema()),
@@ -2150,7 +2173,10 @@ SOURCE: /[\s\S]+/
ToolSpec::Freeform(FreeformTool {
name: PUBLIC_TOOL_NAME.to_string(),
description: code_mode_tool_description(enabled_tools, code_mode_only_enabled),
description: codex_code_mode::build_exec_tool_description(
enabled_tools,
code_mode_only_enabled,
),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
@@ -2621,7 +2647,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
ToolSpec::Freeform(tool) => (tool.name, tool.description),
_ => return None,
};
is_code_mode_nested_tool(&name).then_some((name, description))
codex_code_mode::is_code_mode_nested_tool(&name).then_some((name, description))
})
.collect::<Vec<_>>();
enabled_tools.sort_by(|left, right| left.0.cmp(&right.0));
@@ -2966,12 +2992,15 @@ pub(crate) fn build_specs_with_discoverable_tools(
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
push_tool_spec(
&mut builder,
create_resume_agent_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
if !config.multi_agent_v2 {
push_tool_spec(
&mut builder,
create_resume_agent_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
builder.register_handler("resume_agent", Arc::new(ResumeAgentHandler));
}
push_tool_spec(
&mut builder,
create_wait_agent_tool(),
@@ -2986,7 +3015,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
);
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
builder.register_handler("send_input", Arc::new(SendInputHandler));
builder.register_handler("resume_agent", Arc::new(ResumeAgentHandler));
builder.register_handler("wait_agent", Arc::new(WaitAgentHandler));
builder.register_handler("close_agent", Arc::new(CloseAgentHandler));
}
+94 -1
View File
@@ -469,12 +469,15 @@ 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_resume_agent_tool(),
create_wait_agent_tool(),
create_close_agent_tool(),
] {
expected.insert(tool_name(&spec).to_string(), spec);
}
if !config.multi_agent_v2 {
let spec = create_resume_agent_tool();
expected.insert(tool_name(&spec).to_string(), spec);
}
if config.exec_permission_approvals_enabled {
let spec = create_request_permissions_tool();
@@ -520,6 +523,96 @@ fn test_build_specs_collab_tools_enabled() {
assert_lacks_tool_name(&tools, "spawn_agents_on_csv");
}
#[test]
fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Collab);
features.enable(Feature::MultiAgentV2);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
let spawn_agent = find_tool(&tools, "spawn_agent");
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = &spawn_agent.spec
else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("spawn_agent should use object params");
};
assert!(properties.contains_key("task_name"));
assert_eq!(required.as_ref(), None);
let output_schema = output_schema
.as_ref()
.expect("spawn_agent should define output schema");
assert_eq!(
output_schema["required"],
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 JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("send_input should use object params");
};
assert!(properties.contains_key("target"));
assert_eq!(required.as_ref(), Some(&vec!["target".to_string()]));
let wait_agent = find_tool(&tools, "wait_agent");
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = &wait_agent.spec
else {
panic!("wait_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("wait_agent should use object params");
};
assert!(properties.contains_key("targets"));
assert_eq!(required.as_ref(), Some(&vec!["targets".to_string()]));
let output_schema = output_schema
.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.")
);
assert_lacks_tool_name(&tools, "resume_agent");
}
#[test]
fn test_build_specs_enable_fanout_enables_agent_jobs_and_collab_tools() {
let config = test_config();
+1
View File
@@ -13,6 +13,7 @@ assert_cmd = { workspace = true }
base64 = { workspace = true }
codex-arg0 = { workspace = true }
codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
+23
View File
@@ -289,6 +289,29 @@ pub fn sandbox_network_env_var() -> &'static str {
codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
}
const REMOTE_ENV_ENV_VAR: &str = "CODEX_TEST_REMOTE_ENV";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteEnvConfig {
pub container_name: String,
}
pub fn get_remote_test_env() -> Option<RemoteEnvConfig> {
if std::env::var_os(REMOTE_ENV_ENV_VAR).is_none() {
eprintln!("Skipping test because {REMOTE_ENV_ENV_VAR} is not set.");
return None;
}
let container_name = std::env::var(REMOTE_ENV_ENV_VAR)
.unwrap_or_else(|_| panic!("{REMOTE_ENV_ENV_VAR} must be set"));
assert!(
!container_name.trim().is_empty(),
"{REMOTE_ENV_ENV_VAR} must not be empty"
);
Some(RemoteEnvConfig { container_name })
}
pub fn format_with_current_shell(command: &str) -> Vec<String> {
codex_core::shell::default_user_shell().derive_exec_args(command, /*use_login_shell*/ true)
}
+293 -3
View File
@@ -1,10 +1,16 @@
use std::mem::swap;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use codex_core::CodexAuth;
use codex_core::CodexThread;
use codex_core::ModelProviderInfo;
@@ -14,6 +20,8 @@ use codex_core::config::Config;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::shell::Shell;
use codex_core::shell::get_shell_by_model_provided_path;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ModelsResponse;
@@ -24,10 +32,13 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value;
use tempfile::TempDir;
use wiremock::MockServer;
use crate::RemoteEnvConfig;
use crate::get_remote_test_env;
use crate::load_default_config_for_test;
use crate::responses::WebSocketTestServer;
use crate::responses::output_value_to_text;
@@ -41,6 +52,254 @@ use wiremock::matchers::path_regex;
type ConfigMutator = dyn FnOnce(&mut Config) + Send;
type PreBuildHook = dyn FnOnce(&Path) + Send + 'static;
const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex";
const REMOTE_EXEC_SERVER_START_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_EXEC_SERVER_POLL_INTERVAL: Duration = Duration::from_millis(25);
static REMOTE_EXEC_SERVER_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
struct RemoteExecServerProcess {
container_name: String,
pid: u32,
remote_exec_server_path: String,
stdout_path: String,
cleanup_paths: Vec<String>,
}
impl Drop for RemoteExecServerProcess {
fn drop(&mut self) {
let cleanup_paths = self.cleanup_paths.join(" ");
let cleanup_paths_script = if cleanup_paths.is_empty() {
String::new()
} else {
format!("rm -rf {cleanup_paths}; ")
};
let script = format!(
"if kill -0 {pid} 2>/dev/null; then kill {pid}; fi; {cleanup_paths_script}rm -f {remote_exec_server_path} {stdout_path}",
pid = self.pid,
cleanup_paths_script = cleanup_paths_script,
remote_exec_server_path = self.remote_exec_server_path,
stdout_path = self.stdout_path
);
let _ = docker_command_capture_stdout(["exec", &self.container_name, "sh", "-lc", &script]);
}
}
impl RemoteExecServerProcess {
fn register_cleanup_path(&mut self, path: &Path) {
self.cleanup_paths.push(path.display().to_string());
}
}
#[derive(Debug)]
pub struct TestEnv {
environment: codex_exec_server::Environment,
cwd: PathBuf,
_local_cwd_temp_dir: Option<TempDir>,
_remote_exec_server_process: Option<RemoteExecServerProcess>,
}
impl TestEnv {
pub async fn local() -> Result<Self> {
let local_cwd_temp_dir = TempDir::new()?;
let cwd = local_cwd_temp_dir.path().to_path_buf();
let environment =
codex_exec_server::Environment::create(/*experimental_exec_server_url*/ None).await?;
Ok(Self {
environment,
cwd,
_local_cwd_temp_dir: Some(local_cwd_temp_dir),
_remote_exec_server_process: None,
})
}
pub fn environment(&self) -> &codex_exec_server::Environment {
&self.environment
}
pub fn experimental_exec_server_url(&self) -> Option<&str> {
self.environment.experimental_exec_server_url()
}
}
pub async fn test_env() -> Result<TestEnv> {
match get_remote_test_env() {
Some(remote_env) => {
let mut remote_process = start_remote_exec_server(&remote_env)?;
let remote_ip = remote_container_ip(&remote_env.container_name)?;
let websocket_url = rewrite_websocket_host(&remote_process.listen_url, &remote_ip)?;
let environment = codex_exec_server::Environment::create(Some(websocket_url)).await?;
let cwd = remote_aware_cwd_path();
environment
.get_filesystem()
.create_directory(
&absolute_path(&cwd)?,
CreateDirectoryOptions { recursive: true },
)
.await?;
remote_process.process.register_cleanup_path(&cwd);
Ok(TestEnv {
environment,
cwd,
_local_cwd_temp_dir: None,
_remote_exec_server_process: Some(remote_process.process),
})
}
None => TestEnv::local().await,
}
}
struct RemoteExecServerStart {
process: RemoteExecServerProcess,
listen_url: String,
}
fn start_remote_exec_server(remote_env: &RemoteEnvConfig) -> Result<RemoteExecServerStart> {
let container_name = remote_env.container_name.as_str();
let instance_id = remote_exec_server_instance_id();
let remote_exec_server_path = format!("/tmp/codex-exec-server-{instance_id}");
let stdout_path = format!("/tmp/codex-exec-server-{instance_id}.stdout");
let local_binary = codex_utils_cargo_bin::cargo_bin("codex-exec-server")
.context("resolve codex-exec-server binary")?;
let local_binary = local_binary.to_string_lossy().to_string();
let remote_binary = format!("{container_name}:{remote_exec_server_path}");
docker_command_success(["cp", &local_binary, &remote_binary])?;
docker_command_success([
"exec",
container_name,
"chmod",
"+x",
&remote_exec_server_path,
])?;
let start_script = format!(
"rm -f {stdout_path}; \
nohup {remote_exec_server_path} --listen ws://0.0.0.0:0 > {stdout_path} 2>&1 & \
echo $!"
);
let pid_output =
docker_command_capture_stdout(["exec", container_name, "sh", "-lc", &start_script])?;
let pid = pid_output
.trim()
.parse::<u32>()
.with_context(|| format!("parse remote exec-server PID from {pid_output:?}"))?;
let listen_url = wait_for_remote_listen_url(container_name, &stdout_path)?;
Ok(RemoteExecServerStart {
process: RemoteExecServerProcess {
container_name: container_name.to_string(),
pid,
remote_exec_server_path,
stdout_path,
cleanup_paths: Vec::new(),
},
listen_url,
})
}
fn remote_aware_cwd_path() -> PathBuf {
PathBuf::from(format!(
"/tmp/codex-core-test-cwd-{}",
remote_exec_server_instance_id()
))
}
fn wait_for_remote_listen_url(container_name: &str, stdout_path: &str) -> Result<String> {
let deadline = Instant::now() + REMOTE_EXEC_SERVER_START_TIMEOUT;
loop {
let line = docker_command_capture_stdout([
"exec",
container_name,
"sh",
"-lc",
&format!("head -n 1 {stdout_path} 2>/dev/null || true"),
])?;
let listen_url = line.trim();
if listen_url.starts_with("ws://") {
return Ok(listen_url.to_string());
}
if Instant::now() >= deadline {
return Err(anyhow!(
"timed out waiting for remote exec-server listen URL in container `{container_name}` after {REMOTE_EXEC_SERVER_START_TIMEOUT:?}"
));
}
std::thread::sleep(REMOTE_EXEC_SERVER_POLL_INTERVAL);
}
}
fn remote_exec_server_instance_id() -> String {
let instance = REMOTE_EXEC_SERVER_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{instance}", std::process::id())
}
fn remote_container_ip(container_name: &str) -> Result<String> {
let ip = docker_command_capture_stdout([
"inspect",
"-f",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
container_name,
])?;
let ip = ip.trim();
if ip.is_empty() {
return Err(anyhow!(
"container `{container_name}` has no IP address; cannot connect to remote exec-server"
));
}
Ok(ip.to_string())
}
fn rewrite_websocket_host(listen_url: &str, host: &str) -> Result<String> {
let Some(address) = listen_url.strip_prefix("ws://") else {
return Err(anyhow!(
"unexpected websocket listen URL `{listen_url}`; expected ws://IP:PORT"
));
};
let Some((_, port)) = address.rsplit_once(':') else {
return Err(anyhow!(
"unexpected websocket listen URL `{listen_url}`; expected ws://IP:PORT"
));
};
Ok(format!("ws://{host}:{port}"))
}
fn docker_command_success<const N: usize>(args: [&str; N]) -> Result<()> {
let output = Command::new("docker")
.args(args)
.output()
.with_context(|| format!("run docker {:?}", args))?;
if !output.status.success() {
return Err(anyhow!(
"docker {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
}
fn docker_command_capture_stdout<const N: usize>(args: [&str; N]) -> Result<String> {
let output = Command::new("docker")
.args(args)
.output()
.with_context(|| format!("run docker {:?}", args))?;
if !output.status.success() {
return Err(anyhow!(
"docker {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
String::from_utf8(output.stdout).context("docker stdout must be utf-8")
}
fn absolute_path(path: &Path) -> Result<AbsolutePathBuf> {
AbsolutePathBuf::try_from(path.to_path_buf())
.map_err(|err| anyhow!("invalid absolute path {}: {err}", path.display()))
}
/// A collection of different ways the model can output an apply_patch call
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -124,6 +383,24 @@ impl TestCodexBuilder {
Box::pin(self.build_with_home(server, home, /*resume_from*/ None)).await
}
pub async fn build_remote_aware(
&mut self,
server: &wiremock::MockServer,
) -> anyhow::Result<TestCodex> {
let test_env = test_env().await?;
let experimental_exec_server_url =
test_env.experimental_exec_server_url().map(str::to_owned);
let cwd = test_env.cwd.to_path_buf();
self.config_mutators.push(Box::new(move |config| {
config.experimental_exec_server_url = experimental_exec_server_url;
config.cwd = cwd;
}));
let mut test = self.build(server).await?;
test._test_env = test_env;
Ok(test)
}
pub async fn build_with_streaming_server(
&mut self,
server: &StreamingSseServer,
@@ -176,7 +453,8 @@ impl TestCodexBuilder {
) -> anyhow::Result<TestCodex> {
let base_url = format!("{}/v1", server.uri());
let (config, cwd) = self.prepare_config(base_url, &home).await?;
Box::pin(self.build_from_config(config, cwd, home, resume_from)).await
Box::pin(self.build_from_config(config, cwd, home, resume_from, TestEnv::local().await?))
.await
}
async fn build_with_home_and_base_url(
@@ -186,7 +464,8 @@ impl TestCodexBuilder {
resume_from: Option<PathBuf>,
) -> anyhow::Result<TestCodex> {
let (config, cwd) = self.prepare_config(base_url, &home).await?;
Box::pin(self.build_from_config(config, cwd, home, resume_from)).await
Box::pin(self.build_from_config(config, cwd, home, resume_from, TestEnv::local().await?))
.await
}
async fn build_from_config(
@@ -195,6 +474,7 @@ impl TestCodexBuilder {
cwd: Arc<TempDir>,
home: Arc<TempDir>,
resume_from: Option<PathBuf>,
test_env: TestEnv,
) -> anyhow::Result<TestCodex> {
let auth = self.auth.clone();
let thread_manager = if config.model_catalog.is_some() {
@@ -258,6 +538,7 @@ impl TestCodexBuilder {
codex: new_conversation.thread,
session_configured: new_conversation.session_configured,
thread_manager,
_test_env: test_env,
})
}
@@ -354,6 +635,7 @@ pub struct TestCodex {
pub session_configured: SessionConfiguredEvent,
pub config: Config,
pub thread_manager: Arc<ThreadManager>,
_test_env: TestEnv,
}
impl TestCodex {
@@ -369,6 +651,14 @@ impl TestCodex {
self.cwd_path().join(rel)
}
pub fn executor_environment(&self) -> &TestEnv {
&self._test_env
}
pub fn fs(&self) -> Arc<dyn ExecutorFileSystem> {
self._test_env.environment().get_filesystem()
}
pub async fn submit_turn(&self, prompt: &str) -> Result<()> {
self.submit_turn_with_policies(
prompt,
@@ -431,7 +721,7 @@ impl TestCodex {
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: self.cwd.path().to_path_buf(),
cwd: self.config.cwd.clone(),
approval_policy,
sandbox_policy,
model: session_model,
+110
View File
@@ -381,6 +381,116 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let ctx = RefreshTokenTestContext::new(&server)?;
let stale_refresh = Utc::now() - Duration::days(9);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens),
last_refresh: Some(stale_refresh),
};
ctx.write_auth(&initial_auth)?;
let fresh_refresh = Utc::now() - Duration::days(1);
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
let disk_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(disk_tokens.clone()),
last_refresh: Some(fresh_refresh),
};
save_auth(
ctx.codex_home.path(),
&disk_auth,
AuthCredentialsStoreMode::File,
)?;
let cached_auth = ctx
.auth_manager
.auth()
.await
.context("auth should reload from disk")?;
let cached = cached_auth
.get_token_data()
.context("token data should reload from disk")?;
assert_eq!(cached, disk_tokens);
let stored = ctx.load_auth()?;
assert_eq!(stored, disk_auth);
let requests = server.received_requests().await.unwrap_or_default();
assert!(requests.is_empty(), "expected no refresh token requests");
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {
"code": "refresh_token_expired"
}
})))
.expect(0)
.mount(&server)
.await;
let ctx = RefreshTokenTestContext::new(&server)?;
let stale_refresh = Utc::now() - Duration::days(9);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens),
last_refresh: Some(stale_refresh),
};
ctx.write_auth(&initial_auth)?;
let fresh_refresh = Utc::now() - Duration::days(1);
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
let disk_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(disk_tokens.clone()),
last_refresh: Some(fresh_refresh),
};
save_auth(
ctx.codex_home.path(),
&disk_auth,
AuthCredentialsStoreMode::File,
)?;
let cached_auth = ctx
.auth_manager
.auth()
.await
.context("auth should reload from disk")?;
let cached = cached_auth
.get_token_data()
.context("token data should reload from disk")?;
assert_eq!(cached, disk_tokens);
let stored = ctx.load_auth()?;
assert_eq!(stored, disk_auth);
server.verify().await;
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> {
+5 -14
View File
@@ -1672,8 +1672,6 @@ async fn code_mode_exit_stops_script_immediately() -> Result<()> {
&server,
"use exec to stop script early with exit helper",
r#"
import { exit, text } from "@openai/code_mode";
text("before");
exit();
text("after");
@@ -2129,6 +2127,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort()));
"SuppressedError",
"Symbol",
"SyntaxError",
"Temporal",
"TypeError",
"URIError",
"Uint16Array",
@@ -2141,7 +2140,6 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort()));
"WebAssembly",
"__codexContentItems",
"add_content",
"console",
"decodeURI",
"decodeURIComponent",
"encodeURI",
@@ -2277,20 +2275,13 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
false,
)
.await?;
let test = TestCodex {
home: base_test.home,
cwd: base_test.cwd,
codex: new_thread.thread,
session_configured: new_thread.session_configured,
config: base_test.config,
thread_manager: base_test.thread_manager,
};
let mut test = base_test;
test.codex = new_thread.thread;
test.session_configured = new_thread.session_configured;
let code = r#"
import { ALL_TOOLS, hidden_dynamic_tool } from "tools.js";
const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool");
const out = await hidden_dynamic_tool({ city: "Paris" });
const out = await tools.hidden_dynamic_tool({ city: "Paris" });
text(
JSON.stringify({
name: tool?.name ?? null,
+1
View File
@@ -103,6 +103,7 @@ mod prompt_caching;
mod quota_exceeded;
mod read_file;
mod realtime_conversation;
mod remote_env;
mod remote_models;
mod request_compression;
#[cfg(not(target_os = "windows"))]
@@ -493,6 +493,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> {
let expected = DeveloperInstructions::from_policy(
&sandbox_policy,
AskForApproval::OnRequest,
test.config.approvals_reviewer,
&Policy::empty(),
test.config.cwd.as_path(),
false,
@@ -66,6 +66,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
@@ -110,6 +111,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
+57
View File
@@ -0,0 +1,57 @@
use anyhow::Result;
use codex_exec_server::RemoveOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::get_remote_test_env;
use core_test_support::test_codex::test_env;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
let Some(_remote_env) = get_remote_test_env() else {
return Ok(());
};
let test_env = test_env().await?;
let file_system = test_env.environment().get_filesystem();
let file_path = remote_test_file_path();
let file_path_abs = absolute_path(file_path.clone())?;
let payload = b"remote-test-env-ok".to_vec();
file_system
.write_file(&file_path_abs, payload.clone())
.await?;
let actual = file_system.read_file(&file_path_abs).await?;
assert_eq!(actual, payload);
file_system
.remove(
&file_path_abs,
RemoveOptions {
recursive: false,
force: true,
},
)
.await?;
Ok(())
}
fn absolute_path(path: PathBuf) -> Result<AbsolutePathBuf> {
AbsolutePathBuf::try_from(path.clone())
.map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display()))
}
fn remote_test_file_path() -> PathBuf {
let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos(),
Err(_) => 0,
};
PathBuf::from(format!(
"/tmp/codex-remote-test-env-{}-{nanos}.txt",
std::process::id()
))
}
+2 -21
View File
@@ -1,7 +1,6 @@
use std::time::Duration;
use anyhow::Result;
use codex_features::Feature;
use core_test_support::assert_regex_match;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -251,16 +250,7 @@ async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> {
async fn unicode_output(login: bool) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
#[allow(clippy::expect_used)]
let harness = shell_command_harness_with(|builder| {
builder.with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::PowershellUtf8)
.expect("test config should allow feature update");
})
})
.await?;
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?;
// We use a child process on windows instead of a direct builtin like 'echo' to ensure that Powershell
// config is actually being set correctly.
@@ -286,16 +276,7 @@ async fn unicode_output(login: bool) -> anyhow::Result<()> {
async fn unicode_output_with_newlines(login: bool) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
#[allow(clippy::expect_used)]
let harness = shell_command_harness_with(|builder| {
builder.with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::PowershellUtf8)
.expect("test config should allow feature update");
})
})
.await?;
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?;
let call_id = "unicode_output";
mount_shell_responses_with_timeout(
@@ -141,6 +141,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
originator: "test".to_string(),
cli_version: "test".to_string(),
source: SessionSource::default(),
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
+3 -1
View File
@@ -159,7 +159,9 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
let call_id = "uexec-apply-patch";
let args = json!({
"cmd": command,
"yield_time_ms": 250,
// The intercepted apply_patch path spawns a helper process, which can
// take longer than a tiny unified-exec yield deadline on CI.
"yield_time_ms": 5_000,
});
let responses = vec![
+175 -111
View File
@@ -3,6 +3,7 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::CodexAuth;
use codex_exec_server::CreateDirectoryOptions;
use codex_features::Feature;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -32,12 +33,16 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use image::DynamicImage;
use image::GenericImageView;
use image::ImageBuffer;
use image::Rgba;
use image::load_from_memory;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
use tokio::time::Duration;
use wiremock::BodyPrintLimit;
use wiremock::MockServer;
@@ -73,26 +78,78 @@ fn find_image_message(body: &Value) -> Option<&Value> {
image_messages(body).into_iter().next()
}
fn absolute_path(path: &Path) -> anyhow::Result<codex_utils_absolute_path::AbsolutePathBuf> {
codex_utils_absolute_path::AbsolutePathBuf::try_from(path.to_path_buf())
.map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display()))
}
fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result<Vec<u8>> {
let image = ImageBuffer::from_pixel(width, height, Rgba(rgba));
let mut cursor = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image).write_to(&mut cursor, image::ImageFormat::Png)?;
Ok(cursor.into_inner())
}
async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result<PathBuf> {
let abs_path = test.config.cwd.join(rel_path);
test.fs()
.create_directory(
&absolute_path(&abs_path)?,
CreateDirectoryOptions { recursive: true },
)
.await?;
Ok(abs_path)
}
async fn write_workspace_file(
test: &TestCodex,
rel_path: &str,
contents: Vec<u8>,
) -> anyhow::Result<PathBuf> {
let abs_path = test.config.cwd.join(rel_path);
if let Some(parent) = abs_path.parent() {
test.fs()
.create_directory(
&absolute_path(parent)?,
CreateDirectoryOptions { recursive: true },
)
.await?;
}
test.fs()
.write_file(&absolute_path(&abs_path)?, contents)
.await?;
Ok(abs_path)
}
async fn write_workspace_png(
test: &TestCodex,
rel_path: &str,
width: u32,
height: u32,
rgba: [u8; 4],
) -> anyhow::Result<PathBuf> {
write_workspace_file(test, rel_path, png_bytes(width, height, rgba)?).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = test_codex().build(&server).await?;
} = &test;
let rel_path = "user-turn/example.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let original_width = 2304;
let original_height = 864;
let local_image_dir = tempfile::tempdir()?;
let abs_path = local_image_dir.path().join("example.png");
let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([20u8, 40, 60, 255]));
image.save(&abs_path)?;
@@ -111,7 +168,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> {
path: abs_path.clone(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -124,7 +181,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> {
.await?;
wait_for_event_with_timeout(
&codex,
codex,
|event| matches!(event, EventMsg::TurnComplete(_)),
// Empirically, image attachment can be slow under Bazel/RBE.
Duration::from_secs(10),
@@ -171,23 +228,28 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
session_configured,
config,
..
} = test_codex().build(&server).await?;
} = &test;
let cwd = config.cwd.clone();
let rel_path = "assets/example.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let abs_path = cwd.join(rel_path);
let original_width = 2304;
let original_height = 864;
let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([255u8, 0, 0, 255]));
image.save(&abs_path)?;
write_workspace_png(
&test,
rel_path,
original_width,
original_height,
[255u8, 0, 0, 255],
)
.await?;
let call_id = "view-image-call";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
@@ -214,7 +276,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -228,7 +290,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
let mut tool_event = None;
wait_for_event_with_timeout(
&codex,
codex,
|event| match event {
EventMsg::ViewImageToolCall(_) => {
tool_event = Some(event.clone());
@@ -309,22 +371,25 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5
.enable(Feature::ImageDetailOriginal)
.expect("test config should allow feature update");
});
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = builder.build(&server).await?;
} = &test;
let rel_path = "assets/original-example.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let original_width = 2304;
let original_height = 864;
let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255]));
image.save(&abs_path)?;
write_workspace_png(
&test,
rel_path,
original_width,
original_height,
[0u8, 80, 255, 255],
)
.await?;
let call_id = "view-image-original";
let arguments = serde_json::json!({ "path": rel_path, "detail": "original" }).to_string();
@@ -351,7 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -364,7 +429,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5
.await?;
wait_for_event_with_timeout(
&codex,
codex,
|event| matches!(event, EventMsg::TurnComplete(_)),
Duration::from_secs(10),
)
@@ -413,20 +478,16 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho
.enable(Feature::ImageDetailOriginal)
.expect("test config should allow feature update");
});
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = builder.build(&server).await?;
} = &test;
let rel_path = "assets/unsupported-detail.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let image = ImageBuffer::from_pixel(256, 128, Rgba([0u8, 80, 255, 255]));
image.save(&abs_path)?;
write_workspace_png(&test, rel_path, 256, 128, [0u8, 80, 255, 255]).await?;
let call_id = "view-image-unsupported-detail";
let arguments = serde_json::json!({ "path": rel_path, "detail": "low" }).to_string();
@@ -453,7 +514,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -465,7 +526,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let req = mock.single_request();
let body_with_tool_output = req.body_json();
@@ -499,22 +560,25 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> {
.enable(Feature::ImageDetailOriginal)
.expect("test config should allow feature update");
});
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = builder.build(&server).await?;
} = &test;
let rel_path = "assets/null-detail.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let original_width = 2304;
let original_height = 864;
let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255]));
image.save(&abs_path)?;
write_workspace_png(
&test,
rel_path,
original_width,
original_height,
[0u8, 80, 255, 255],
)
.await?;
let call_id = "view-image-null-detail";
let arguments = serde_json::json!({ "path": rel_path, "detail": null }).to_string();
@@ -541,7 +605,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> {
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -553,7 +617,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> {
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let req = mock.single_request();
let function_output = req.function_call_output(call_id);
@@ -595,22 +659,25 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a
.enable(Feature::ImageDetailOriginal)
.expect("test config should allow feature update");
});
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = builder.build(&server).await?;
} = &test;
let rel_path = "assets/original-example-lower-model.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let original_width = 2304;
let original_height = 864;
let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255]));
image.save(&abs_path)?;
write_workspace_png(
&test,
rel_path,
original_width,
original_height,
[0u8, 80, 255, 255],
)
.await?;
let call_id = "view-image-original-lower-model";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
@@ -637,7 +704,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -650,7 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a
.await?;
wait_for_event_with_timeout(
&codex,
codex,
|event| matches!(event, EventMsg::TurnComplete(_)),
Duration::from_secs(10),
)
@@ -702,22 +769,25 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat
.enable(Feature::ImageDetailOriginal)
.expect("test config should allow feature update");
});
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = builder.build(&server).await?;
} = &test;
let rel_path = "assets/original-example-capability-only.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let original_width = 2304;
let original_height = 864;
let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255]));
image.save(&abs_path)?;
write_workspace_png(
&test,
rel_path,
original_width,
original_height,
[0u8, 80, 255, 255],
)
.await?;
let call_id = "view-image-capability-only";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
@@ -744,7 +814,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -757,7 +827,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat
.await?;
wait_for_event_with_timeout(
&codex,
codex,
|event| matches!(event, EventMsg::TurnComplete(_)),
Duration::from_secs(10),
)
@@ -1019,16 +1089,17 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = test_codex().build(&server).await?;
} = &test;
let rel_path = "assets";
let abs_path = cwd.path().join(rel_path);
std::fs::create_dir_all(&abs_path)?;
let abs_path = create_workspace_directory(&test, rel_path).await?;
let call_id = "view-image-directory";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
@@ -1055,7 +1126,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -1067,7 +1138,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let req = mock.single_request();
let body_with_tool_output = req.body_json();
@@ -1092,19 +1163,18 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = test_codex().build(&server).await?;
} = &test;
let rel_path = "assets/example.json";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&abs_path, br#"{ "message": "hello" }"#)?;
let abs_path =
write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?;
let call_id = "view-image-non-image";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
@@ -1131,7 +1201,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -1143,7 +1213,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let request = mock.single_request();
assert!(
@@ -1174,15 +1244,17 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = test_codex().build(&server).await?;
} = &test;
let rel_path = "missing/example.png";
let abs_path = cwd.path().join(rel_path);
let abs_path = config.cwd.join(rel_path);
let call_id = "view-image-missing";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
@@ -1209,7 +1281,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
@@ -1221,7 +1293,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let req = mock.single_request();
let body_with_tool_output = req.body_json();
@@ -1298,21 +1370,16 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
)
.await;
let TestCodex { codex, cwd, .. } = test_codex()
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
config.model = Some(model_slug.to_string());
})
.build(&server)
.await?;
});
let test = builder.build_remote_aware(&server).await?;
let TestCodex { codex, config, .. } = &test;
let rel_path = "assets/example.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let image = ImageBuffer::from_pixel(20, 20, Rgba([255u8, 0, 0, 255]));
image.save(&abs_path)?;
write_workspace_png(&test, rel_path, 20, 20, [255u8, 0, 0, 255]).await?;
let call_id = "view-image-unsupported-model";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
@@ -1336,7 +1403,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: model_slug.to_string(),
@@ -1348,7 +1415,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let output_text = mock
.single_request()
@@ -1390,20 +1457,17 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()>
let completion_mock = responses::mount_sse_once(&server, success_response).await;
let mut builder = test_codex();
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
cwd,
config,
session_configured,
..
} = test_codex().build(&server).await?;
} = &test;
let rel_path = "assets/poisoned.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let image = ImageBuffer::from_pixel(1024, 512, Rgba([10u8, 20, 30, 255]));
image.save(&abs_path)?;
let abs_path = write_workspace_png(&test, rel_path, 1024, 512, [10u8, 20, 30, 255]).await?;
let session_model = session_configured.model.clone();
@@ -1413,7 +1477,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()>
path: abs_path.clone(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,