This commit is contained in:
Roy Han
2026-03-31 15:07:23 -07:00
1807 changed files with 41233 additions and 163241 deletions
+83 -23
View File
@@ -392,6 +392,9 @@
"fast_mode": {
"type": "boolean"
},
"general_analytics": {
"type": "boolean"
},
"guardian_approval": {
"type": "boolean"
},
@@ -503,9 +506,6 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"voice_transcription": {
"type": "boolean"
},
"web_search": {
"type": "boolean"
},
@@ -819,10 +819,62 @@
},
"type": "object"
},
"ModelProviderAuthInfo": {
"additionalProperties": false,
"description": "Configuration for obtaining a provider bearer token from a command.",
"properties": {
"args": {
"default": [],
"description": "Command arguments.",
"items": {
"type": "string"
},
"type": "array"
},
"command": {
"description": "Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`.",
"type": "string"
},
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory used when running the token command."
},
"refresh_interval_ms": {
"default": 300000,
"description": "Maximum age for the cached token before rerunning the command.",
"format": "uint64",
"minimum": 1.0,
"type": "integer"
},
"timeout_ms": {
"default": 5000,
"description": "Maximum time to wait for the token command to exit successfully.",
"format": "uint64",
"minimum": 1.0,
"type": "integer"
}
},
"required": [
"command"
],
"type": "object"
},
"ModelProviderInfo": {
"additionalProperties": false,
"description": "Serializable representation of a provider definition.",
"properties": {
"auth": {
"allOf": [
{
"$ref": "#/definitions/ModelProviderAuthInfo"
}
],
"description": "Command-backed bearer-token configuration for this provider."
},
"base_url": {
"description": "Base URL for the provider's OpenAI-compatible API.",
"type": "string"
@@ -913,6 +965,16 @@
],
"type": "object"
},
"NetworkDomainPermissionToml": {
"enum": [
"allow",
"deny"
],
"type": "string"
},
"NetworkDomainPermissionsToml": {
"type": "object"
},
"NetworkModeSchema": {
"enum": [
"limited",
@@ -926,32 +988,17 @@
"allow_local_binding": {
"type": "boolean"
},
"allow_unix_sockets": {
"items": {
"type": "string"
},
"type": "array"
},
"allow_upstream_proxy": {
"type": "boolean"
},
"allowed_domains": {
"items": {
"type": "string"
},
"type": "array"
},
"dangerously_allow_all_unix_sockets": {
"type": "boolean"
},
"dangerously_allow_non_loopback_proxy": {
"type": "boolean"
},
"denied_domains": {
"items": {
"type": "string"
},
"type": "array"
"domains": {
"$ref": "#/definitions/NetworkDomainPermissionsToml"
},
"enable_socks5": {
"type": "boolean"
@@ -970,10 +1017,23 @@
},
"socks_url": {
"type": "string"
},
"unix_sockets": {
"$ref": "#/definitions/NetworkUnixSocketPermissionsToml"
}
},
"type": "object"
},
"NetworkUnixSocketPermissionToml": {
"enum": [
"allow",
"none"
],
"type": "string"
},
"NetworkUnixSocketPermissionsToml": {
"type": "object"
},
"Notice": {
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
"properties": {
@@ -2029,6 +2089,9 @@
"fast_mode": {
"type": "boolean"
},
"general_analytics": {
"type": "boolean"
},
"guardian_approval": {
"type": "boolean"
},
@@ -2140,9 +2203,6 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"voice_transcription": {
"type": "boolean"
},
"web_search": {
"type": "boolean"
},
-19
View File
@@ -28,25 +28,6 @@ pub(crate) async fn resolve_agent_target(
})
}
/// 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
+179 -197
View File
@@ -5,10 +5,8 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::context_manager::is_user_turn_boundary;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::event_mapping::parse_turn_item;
use crate::find_archived_thread_path_by_id_str;
use crate::find_thread_path_by_id_str;
use crate::rollout::RolloutRecorder;
@@ -17,13 +15,11 @@ use crate::session_prefix::format_subagent_notification_message;
use crate::shell_snapshot::ShellSnapshot;
use crate::state_db;
use crate::thread_manager::ThreadManagerState;
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
@@ -46,9 +42,16 @@ const AGENT_NAMES: &str = include_str!("agent_names.txt");
const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.";
const ROOT_LAST_TASK_MESSAGE: &str = "Main thread";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SpawnAgentForkMode {
FullHistory,
LastNTurns(usize),
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SpawnAgentOptions {
pub(crate) fork_parent_spawn_call_id: Option<String>,
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
}
#[derive(Clone, Debug)]
@@ -118,30 +121,36 @@ impl AgentControl {
pub(crate) async fn spawn_agent(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
initial_operation: Op,
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
Ok(self
.spawn_agent_internal(config, items, session_source, SpawnAgentOptions::default())
.spawn_agent_internal(
config,
initial_operation,
session_source,
SpawnAgentOptions::default(),
)
.await?
.thread_id)
}
/// Spawn an agent thread with some metadata.
pub(crate) async fn spawn_agent_with_metadata(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
initial_operation: Op,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
options: SpawnAgentOptions, // TODO(jif) drop with new fork.
) -> CodexResult<LiveAgent> {
self.spawn_agent_internal(config, items, session_source, options)
self.spawn_agent_internal(config, initial_operation, session_source, options)
.await
}
async fn spawn_agent_internal(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
initial_operation: Op,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
) -> CodexResult<LiveAgent> {
@@ -177,83 +186,32 @@ impl AgentControl {
let notification_source = session_source.clone();
// The same `AgentControl` is sent to spawn the thread.
let new_thread = match session_source {
Some(session_source) => {
if let Some(call_id) = options.fork_parent_spawn_call_id.as_ref() {
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
..
}) = session_source.clone()
else {
return Err(CodexErr::Fatal(
"spawn_agent fork requires a thread-spawn session source".to_string(),
));
};
let parent_thread = state.get_thread(parent_thread_id).await.ok();
if let Some(parent_thread) = parent_thread.as_ref() {
// `record_conversation_items` only queues rollout writes asynchronously.
// Flush/materialize the live parent before snapshotting JSONL for a fork.
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread.codex.session.flush_rollout().await;
}
let rollout_path = parent_thread
.as_ref()
.and_then(|parent_thread| parent_thread.rollout_path())
.or(find_thread_path_by_id_str(
config.codex_home.as_path(),
&parent_thread_id.to_string(),
)
.await?)
.ok_or_else(|| {
CodexErr::Fatal(format!(
"parent thread rollout unavailable for fork: {parent_thread_id}"
))
})?;
let mut forked_rollout_items: Vec<RolloutItem> =
RolloutRecorder::get_rollout_history(&rollout_path)
.await?
.get_rollout_items();
let mut output = FunctionCallOutputPayload::from_text(
FORKED_SPAWN_AGENT_OUTPUT_MESSAGE.to_string(),
);
output.success = Some(true);
forked_rollout_items.push(RolloutItem::ResponseItem(
ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output,
},
));
let initial_history = InitialHistory::Forked(forked_rollout_items);
state
.fork_thread_with_source(
config,
initial_history,
self.clone(),
session_source,
/*persist_extended_history*/ false,
inherited_shell_snapshot,
inherited_exec_policy,
)
.await?
} else {
state
.spawn_new_thread_with_source(
config,
self.clone(),
session_source,
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
inherited_shell_snapshot,
inherited_exec_policy,
)
.await?
}
let new_thread = match (session_source, options.fork_mode.as_ref()) {
(Some(session_source), Some(_)) => {
self.spawn_forked_thread(
&state,
config,
session_source,
&options,
inherited_shell_snapshot,
inherited_exec_policy,
)
.await?
}
None => state.spawn_new_thread(config, self.clone()).await?,
(Some(session_source), None) => {
state
.spawn_new_thread_with_source(
config,
self.clone(),
session_source,
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
inherited_shell_snapshot,
inherited_exec_policy,
)
.await?
}
(None, _) => state.spawn_new_thread(config, self.clone()).await?,
};
agent_metadata.agent_id = Some(new_thread.thread_id);
reservation.commit(agent_metadata.clone());
@@ -270,18 +228,21 @@ impl AgentControl {
)
.await;
self.send_input(new_thread.thread_id, items).await?;
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,
agent_metadata.agent_path.clone(),
);
self.send_input(new_thread.thread_id, initial_operation)
.await?;
if !new_thread.thread.enabled(Feature::MultiAgentV2) {
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,
agent_metadata.agent_path.clone(),
);
}
Ok(LiveAgent {
thread_id: new_thread.thread_id,
@@ -290,6 +251,92 @@ impl AgentControl {
})
}
async fn spawn_forked_thread(
&self,
state: &Arc<ThreadManagerState>,
config: crate::config::Config,
session_source: SessionSource,
options: &SpawnAgentOptions,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
) -> CodexResult<crate::thread_manager::NewThread> {
let Some(call_id) = options.fork_parent_spawn_call_id.as_deref() else {
return Err(CodexErr::Fatal(
"spawn_agent fork requires a parent spawn call id".to_string(),
));
};
let Some(fork_mode) = options.fork_mode.as_ref() else {
return Err(CodexErr::Fatal(
"spawn_agent fork requires a fork mode".to_string(),
));
};
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) = &session_source
else {
return Err(CodexErr::Fatal(
"spawn_agent fork requires a thread-spawn session source".to_string(),
));
};
let parent_thread_id = *parent_thread_id;
let parent_thread = state.get_thread(parent_thread_id).await.ok();
if let Some(parent_thread) = parent_thread.as_ref() {
// `record_conversation_items` only queues rollout writes asynchronously.
// Flush/materialize the live parent before snapshotting JSONL for a fork.
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread.codex.session.flush_rollout().await;
}
let rollout_path = parent_thread
.as_ref()
.and_then(|parent_thread| parent_thread.rollout_path())
.or(find_thread_path_by_id_str(
config.codex_home.as_path(),
&parent_thread_id.to_string(),
)
.await?)
.ok_or_else(|| {
CodexErr::Fatal(format!(
"parent thread rollout unavailable for fork: {parent_thread_id}"
))
})?;
let mut forked_rollout_items = RolloutRecorder::get_rollout_history(&rollout_path)
.await?
.get_rollout_items();
if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode {
forked_rollout_items =
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
}
let mut output =
FunctionCallOutputPayload::from_text(FORKED_SPAWN_AGENT_OUTPUT_MESSAGE.to_string());
output.success = Some(true);
forked_rollout_items.push(RolloutItem::ResponseItem(
ResponseItem::FunctionCallOutput {
call_id: call_id.to_string(),
output,
},
));
state
.fork_thread_with_source(
config,
InitialHistory::Forked(forked_rollout_items),
self.clone(),
session_source,
/*persist_extended_history*/ false,
inherited_shell_snapshot,
inherited_exec_policy,
)
.await
}
/// Resume an existing agent thread from a recorded rollout file.
pub(crate) async fn resume_agent_from_rollout(
&self,
@@ -443,17 +490,19 @@ impl AgentControl {
// 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,
agent_metadata.agent_path.clone(),
);
if !resumed_thread.thread.enabled(Feature::MultiAgentV2) {
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,
agent_metadata.agent_path.clone(),
);
}
self.persist_thread_spawn_edge_for_source(
resumed_thread.thread.as_ref(),
resumed_thread.thread_id,
@@ -468,23 +517,15 @@ impl AgentControl {
pub(crate) async fn send_input(
&self,
agent_id: ThreadId,
items: Vec<UserInput>,
initial_operation: Op,
) -> CodexResult<String> {
let last_task_message = render_input_preview(&items);
let last_task_message = render_input_preview(&initial_operation);
let state = self.upgrade()?;
let result = self
.handle_thread_request_result(
agent_id,
&state,
state
.send_op(
agent_id,
Op::UserInput {
items,
final_output_json_schema: None,
},
)
.await,
state.send_op(agent_id, initial_operation).await,
)
.await;
if result.is_ok() {
@@ -766,10 +807,7 @@ impl AgentControl {
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| thread_id.to_string());
let last_task_message = match metadata.last_task_message.clone() {
Some(last_task_message) => Some(last_task_message),
None => last_task_message_for_thread(thread.as_ref()).await,
};
let last_task_message = metadata.last_task_message.clone();
agents.push(ListedAgent {
agent_name,
agent_status: thread.agent_status().await,
@@ -1072,79 +1110,23 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b
})
}
async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option<String> {
let pending_input = thread.codex.session.pending_input_snapshot().await;
if let Some(message) = pending_input
.iter()
.rev()
.find_map(last_task_message_from_input_item)
{
return Some(message);
pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
match initial_operation {
Op::UserInput { items, .. } => items
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n"),
Op::InterAgentCommunication { communication } => communication.content.clone(),
_ => String::new(),
}
let queued_input = thread
.codex
.session
.queued_response_items_for_next_turn_snapshot()
.await;
if let Some(message) = queued_input
.iter()
.rev()
.find_map(last_task_message_from_input_item)
{
return Some(message);
}
let history = thread.codex.session.clone_history().await;
history
.raw_items()
.iter()
.rev()
.find_map(last_task_message_from_item)
}
fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option<String> {
let response_item: ResponseItem = item.clone().into();
last_task_message_from_item(&response_item)
}
fn last_task_message_from_item(item: &ResponseItem) -> Option<String> {
if !is_user_turn_boundary(item) {
return None;
}
match item {
ResponseItem::Message { role, .. } if role == "user" => {
let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else {
return None;
};
Some(render_input_preview(&message.content))
}
ResponseItem::Message { content, .. } => match content.as_slice() {
[ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.map(|communication| communication.content)
}
_ => None,
},
_ => None,
}
}
fn render_input_preview(items: &[UserInput]) -> String {
items
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {
+191 -27
View File
@@ -54,11 +54,12 @@ async fn test_config() -> (TempDir, Config) {
test_config_with_cli_overrides(Vec::new()).await
}
fn text_input(text: &str) -> Vec<UserInput> {
fn text_input(text: &str) -> Op {
vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}]
.into()
}
struct AgentControlHarness {
@@ -217,7 +218,8 @@ async fn send_input_errors_when_manager_dropped() {
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
)
.await
.expect_err("send_input should fail without a manager");
@@ -287,7 +289,7 @@ async fn spawn_agent_errors_when_manager_dropped() {
let control = AgentControl::default();
let (_home, config) = test_config().await;
let err = control
.spawn_agent(config, text_input("hello"), None)
.spawn_agent(config, text_input("hello"), /*session_source*/ None)
.await
.expect_err("spawn_agent should fail without a manager");
assert_eq!(
@@ -321,7 +323,8 @@ async fn send_input_errors_when_thread_missing() {
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
)
.await
.expect_err("send_input should fail for missing thread");
@@ -387,7 +390,8 @@ async fn send_input_submits_user_message() {
vec![UserInput::Text {
text: "hello from tests".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
)
.await
.expect("send_input should succeed");
@@ -419,7 +423,7 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"hello from tests".to_string(),
false,
/*trigger_turn*/ false,
);
let submission_id = harness
@@ -444,12 +448,7 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
timeout(Duration::from_secs(5), async {
loop {
if thread
.codex
.session
.has_queued_response_items_for_next_turn()
.await
{
if thread.codex.session.has_pending_input().await {
break;
}
sleep(Duration::from_millis(10)).await;
@@ -531,7 +530,11 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
let harness = AgentControlHarness::new().await;
let thread_id = harness
.control
.spawn_agent(harness.config.clone(), text_input("spawned"), None)
.spawn_agent(
harness.config.clone(),
text_input("spawned"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let _thread = harness
@@ -599,6 +602,7 @@ async fn spawn_agent_can_fork_parent_thread_history() {
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
},
)
.await
@@ -684,6 +688,7 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
},
)
.await
@@ -756,6 +761,7 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
},
)
.await
@@ -802,6 +808,128 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
parent_thread
.inject_user_message_without_turn("old parent context".to_string())
.await;
let queued_communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"queued message".to_string(),
/*trigger_turn*/ false,
);
let queued_turn_context = parent_thread.codex.session.new_default_turn().await;
parent_thread
.codex
.session
.record_conversation_items(
queued_turn_context.as_ref(),
&[queued_communication.to_response_input_item().into()],
)
.await;
let triggered_communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"triggered context".to_string(),
/*trigger_turn*/ true,
);
let triggered_turn_context = parent_thread.codex.session.new_default_turn().await;
parent_thread
.codex
.session
.record_conversation_items(
triggered_turn_context.as_ref(),
&[triggered_communication.to_response_input_item().into()],
)
.await;
parent_thread
.inject_user_message_without_turn("current parent task".to_string())
.await;
let spawn_turn_context = parent_thread.codex.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-last-n".to_string();
let parent_spawn_call = ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: parent_spawn_call_id.clone(),
};
parent_thread
.codex
.session
.record_conversation_items(spawn_turn_context.as_ref(), &[parent_spawn_call])
.await;
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread.codex.session.flush_rollout().await;
let child_thread_id = harness
.control
.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,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
},
)
.await
.expect("forked spawn should keep only the last two turns")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.codex.session.clone_history().await;
assert!(!history_contains_text(
history.raw_items(),
"old parent context"
));
assert!(!history_contains_text(
history.raw_items(),
"queued message"
));
assert!(history_contains_text(
history.raw_items(),
"triggered context"
));
assert!(history_contains_text(
history.raw_items(),
"current parent task"
));
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_respects_max_threads_limit() {
let max_threads = 1usize;
@@ -826,12 +954,20 @@ async fn spawn_agent_respects_max_threads_limit() {
.expect("start thread");
let first_agent_id = control
.spawn_agent(config.clone(), text_input("hello"), None)
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let err = control
.spawn_agent(config, text_input("hello again"), None)
.spawn_agent(
config,
text_input("hello again"),
/*session_source*/ None,
)
.await
.expect_err("spawn_agent should respect max threads");
let CodexErr::AgentLimitReached {
@@ -867,7 +1003,11 @@ async fn spawn_agent_releases_slot_after_shutdown() {
let control = manager.agent_control();
let first_agent_id = control
.spawn_agent(config.clone(), text_input("hello"), None)
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let _ = control
@@ -876,7 +1016,11 @@ async fn spawn_agent_releases_slot_after_shutdown() {
.expect("shutdown agent");
let second_agent_id = control
.spawn_agent(config.clone(), text_input("hello again"), None)
.spawn_agent(
config.clone(),
text_input("hello again"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed after shutdown");
let _ = control
@@ -905,12 +1049,20 @@ async fn spawn_agent_limit_shared_across_clones() {
let cloned = control.clone();
let first_agent_id = cloned
.spawn_agent(config.clone(), text_input("hello"), None)
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let err = control
.spawn_agent(config, text_input("hello again"), None)
.spawn_agent(
config,
text_input("hello again"),
/*session_source*/ None,
)
.await
.expect_err("spawn_agent should respect shared guard");
let CodexErr::AgentLimitReached { max_threads } = err else {
@@ -943,7 +1095,11 @@ async fn resume_agent_respects_max_threads_limit() {
let control = manager.agent_control();
let resumable_id = control
.spawn_agent(config.clone(), text_input("hello"), None)
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let _ = control
@@ -952,7 +1108,11 @@ async fn resume_agent_respects_max_threads_limit() {
.expect("shutdown resumable thread");
let active_id = control
.spawn_agent(config.clone(), text_input("occupy"), None)
.spawn_agent(
config.clone(),
text_input("occupy"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed for active slot");
@@ -998,7 +1158,7 @@ async fn resume_agent_releases_slot_after_resume_failure() {
.expect_err("resume should fail for missing rollout path");
let resumed_id = control
.spawn_agent(config, text_input("hello"), None)
.spawn_agent(config, text_input("hello"), /*session_source*/ None)
.await
.expect("spawn should succeed after failed resume");
let _ = control
@@ -1136,7 +1296,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
AgentPath::root(),
Vec::new(),
"done".to_string(),
true,
/*trigger_turn*/ true,
)
));
assert!(!has_subagent_notification(&root_history_items));
@@ -1199,7 +1359,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
worker_path.clone(),
Vec::new(),
expected_message.clone(),
false,
/*trigger_turn*/ false,
),
},
);
@@ -1234,7 +1394,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
AgentPath::root(),
Vec::new(),
expected_message,
false,
/*trigger_turn*/ false,
)
));
}
@@ -1255,7 +1415,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
agent_role: Some("explorer".to_string()),
})),
child_thread_id.to_string(),
None,
/*child_agent_path*/ None,
);
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
@@ -1516,7 +1676,11 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
let harness = AgentControlHarness::new().await;
let child_thread_id = harness
.control
.spawn_agent(harness.config.clone(), text_input("hello"), None)
.spawn_agent(
harness.config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("child spawn should succeed");
+161
View File
@@ -0,0 +1,161 @@
use codex_protocol::protocol::InterAgentCommunication;
use std::collections::VecDeque;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use tokio::sync::mpsc;
use tokio::sync::watch;
#[cfg(test)]
use codex_protocol::AgentPath;
pub(crate) struct Mailbox {
tx: mpsc::UnboundedSender<InterAgentCommunication>,
next_seq: AtomicU64,
seq_tx: watch::Sender<u64>,
}
pub(crate) struct MailboxReceiver {
rx: mpsc::UnboundedReceiver<InterAgentCommunication>,
pending_mails: VecDeque<InterAgentCommunication>,
}
impl Mailbox {
pub(crate) fn new() -> (Self, MailboxReceiver) {
let (tx, rx) = mpsc::unbounded_channel();
let (seq_tx, _) = watch::channel(0);
(
Self {
tx,
next_seq: AtomicU64::new(0),
seq_tx,
},
MailboxReceiver {
rx,
pending_mails: VecDeque::new(),
},
)
}
pub(crate) fn subscribe(&self) -> watch::Receiver<u64> {
self.seq_tx.subscribe()
}
pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 {
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1;
let _ = self.tx.send(communication);
self.seq_tx.send_replace(seq);
seq
}
}
impl MailboxReceiver {
fn sync_pending_mails(&mut self) {
while let Ok(mail) = self.rx.try_recv() {
self.pending_mails.push_back(mail);
}
}
pub(crate) fn has_pending(&mut self) -> bool {
self.sync_pending_mails();
!self.pending_mails.is_empty()
}
pub(crate) fn has_pending_trigger_turn(&mut self) -> bool {
self.sync_pending_mails();
self.pending_mails.iter().any(|mail| mail.trigger_turn)
}
pub(crate) fn drain(&mut self) -> Vec<InterAgentCommunication> {
self.sync_pending_mails();
self.pending_mails.drain(..).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn make_mail(
author: AgentPath,
recipient: AgentPath,
content: &str,
trigger_turn: bool,
) -> InterAgentCommunication {
InterAgentCommunication::new(
author,
recipient,
Vec::new(),
content.to_string(),
trigger_turn,
)
}
#[tokio::test]
async fn mailbox_assigns_monotonic_sequence_numbers() {
let (mailbox, _receiver) = Mailbox::new();
let mut seq_rx = mailbox.subscribe();
let seq_a = mailbox.send(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"one",
/*trigger_turn*/ false,
));
let seq_b = mailbox.send(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"two",
/*trigger_turn*/ false,
));
seq_rx.changed().await.expect("first seq update");
assert_eq!(*seq_rx.borrow(), seq_b);
assert_eq!(seq_a, 1);
assert_eq!(seq_b, 2);
}
#[tokio::test]
async fn mailbox_drains_in_delivery_order() {
let (mailbox, mut receiver) = Mailbox::new();
let mail_one = make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"one",
/*trigger_turn*/ false,
);
let mail_two = make_mail(
AgentPath::try_from("/root/worker").expect("agent path"),
AgentPath::root(),
"two",
/*trigger_turn*/ false,
);
mailbox.send(mail_one.clone());
mailbox.send(mail_two.clone());
assert_eq!(receiver.drain(), vec![mail_one, mail_two]);
assert!(!receiver.has_pending());
}
#[tokio::test]
async fn mailbox_tracks_pending_trigger_turn_mail() {
let (mailbox, mut receiver) = Mailbox::new();
mailbox.send(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"queued",
/*trigger_turn*/ false,
));
assert!(!receiver.has_pending_trigger_turn());
mailbox.send(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"wake",
/*trigger_turn*/ true,
));
assert!(receiver.has_pending_trigger_turn());
}
}
+3
View File
@@ -1,11 +1,14 @@
pub(crate) mod agent_resolver;
pub(crate) mod control;
pub(crate) mod mailbox;
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 mailbox::Mailbox;
pub(crate) use mailbox::MailboxReceiver;
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;
+46 -20
View File
@@ -16,11 +16,26 @@ fn agent_metadata(thread_id: ThreadId) -> AgentMetadata {
#[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");
assert_eq!(
format_agent_nickname("Plato", /*nickname_reset_count*/ 0),
"Plato"
);
assert_eq!(
format_agent_nickname("Plato", /*nickname_reset_count*/ 1),
"Plato the 2nd"
);
assert_eq!(
format_agent_nickname("Plato", /*nickname_reset_count*/ 2),
"Plato the 3rd"
);
assert_eq!(
format_agent_nickname("Plato", /*nickname_reset_count*/ 10),
"Plato the 11th"
);
assert_eq!(
format_agent_nickname("Plato", /*nickname_reset_count*/ 20),
"Plato the 21st"
);
}
#[test]
@@ -39,7 +54,10 @@ fn thread_spawn_depth_increments_and_enforces_limit() {
});
let child_depth = next_thread_spawn_depth(&session_source);
assert_eq!(child_depth, 2);
assert!(exceeds_thread_spawn_depth_limit(child_depth, 1));
assert!(exceeds_thread_spawn_depth_limit(
child_depth,
/*max_depth*/ 1
));
}
#[test]
@@ -47,7 +65,9 @@ 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));
assert!(!exceeds_thread_spawn_depth_limit(
/*depth*/ 1, /*max_depth*/ 1
));
}
#[test]
@@ -142,14 +162,18 @@ fn release_is_idempotent_for_registered_threads() {
#[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 mut reservation = registry
.reserve_spawn_slot(/*max_threads*/ 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 mut reservation = registry
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
.expect("unused name should still be preferred");
@@ -160,7 +184,7 @@ fn failed_spawn_keeps_nickname_marked_used() {
fn agent_nickname_resets_used_pool_when_exhausted() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve first slot");
let first_name = first
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
@@ -170,7 +194,7 @@ fn agent_nickname_resets_used_pool_when_exhausted() {
assert_eq!(first_name, "alpha");
let mut second = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
@@ -188,7 +212,7 @@ fn released_nickname_stays_used_until_pool_reset() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve first slot");
let first_name = first
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
@@ -200,7 +224,7 @@ fn released_nickname_stays_used_until_pool_reset() {
registry.release_spawned_thread(first_id);
let mut second = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
@@ -211,7 +235,7 @@ fn released_nickname_stays_used_until_pool_reset() {
registry.release_spawned_thread(second_id);
let mut third = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve third slot");
let third_name = third
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
@@ -230,7 +254,7 @@ fn repeated_resets_advance_the_ordinal_suffix() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve first slot");
let first_name = first
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
@@ -241,7 +265,7 @@ fn repeated_resets_advance_the_ordinal_suffix() {
registry.release_spawned_thread(first_id);
let mut second = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
@@ -252,7 +276,7 @@ fn repeated_resets_advance_the_ordinal_suffix() {
registry.release_spawned_thread(second_id);
let mut third = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve third slot");
let third_name = third
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
@@ -282,7 +306,7 @@ fn register_root_thread_indexes_root_path() {
fn reserved_agent_path_is_released_when_spawn_fails() {
let registry = Arc::new(AgentRegistry::default());
let mut first = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve first slot");
first
.reserve_agent_path(&agent_path("/root/researcher"))
@@ -290,7 +314,7 @@ fn reserved_agent_path_is_released_when_spawn_fails() {
drop(first);
let mut second = registry
.reserve_spawn_slot(None)
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve second slot");
second
.reserve_agent_path(&agent_path("/root/researcher"))
@@ -301,7 +325,9 @@ fn reserved_agent_path_is_released_when_spawn_fails() {
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");
let mut reservation = registry
.reserve_spawn_slot(/*max_threads*/ None)
.expect("reserve slot");
reservation
.reserve_agent_path(&agent_path("/root/researcher"))
.expect("reserve path");
+13 -4
View File
@@ -40,7 +40,10 @@ async fn write_role_config(home: &TempDir, name: &str, contents: &str) -> PathBu
fn session_flags_layer_count(config: &Config) -> usize {
config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| layer.name == ConfigLayerSource::SessionFlags)
.count()
@@ -51,7 +54,7 @@ async fn apply_role_defaults_to_default_and_leaves_config_unchanged() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let before = config.clone();
apply_role_to_config(&mut config, None)
apply_role_to_config(&mut config, /*role_name*/ None)
.await
.expect("default role should apply");
@@ -529,7 +532,10 @@ writable_roots = ["./sandbox-root"]
let role_layer = config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.rfind(|layer| layer.name == ConfigLayerSource::SessionFlags)
.expect("expected a session flags layer");
@@ -630,7 +636,10 @@ enabled = false
.expect("custom role should apply");
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
let skills_manager = SkillsManager::new(home.path().to_path_buf(), true);
let skills_manager = SkillsManager::new(
home.path().to_path_buf(),
/*bundled_skills_enabled*/ true,
);
let plugin_outcome = plugins_manager.plugins_for_config(&config);
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
+3 -1
View File
@@ -64,6 +64,7 @@ mod tests {
env_key: Some("sk-should-not-leak".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: crate::model_provider_info::WireApi::Responses,
query_params: None,
http_headers: None,
@@ -76,7 +77,8 @@ mod tests {
supports_websockets: false,
};
let telemetry = collect_auth_env_telemetry(&provider, false);
let telemetry =
collect_auth_env_telemetry(&provider, /*codex_api_key_env_enabled*/ false);
assert_eq!(
telemetry.provider_env_key_name,
+7 -1
View File
@@ -72,6 +72,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_tools::create_tools_json_for_responses_api;
use eventsource_stream::Event;
use eventsource_stream::EventStreamError;
use futures::StreamExt;
@@ -103,11 +104,11 @@ use crate::error::Result;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::provider_auth::auth_manager_for_provider;
use crate::response_debug_context::extract_response_debug_context;
use crate::response_debug_context::extract_response_debug_context_from_api_error;
use crate::response_debug_context::telemetry_api_error_message;
use crate::response_debug_context::telemetry_transport_error_message;
use crate::tools::spec::create_tools_json_for_responses_api;
use crate::util::FeedbackRequestTags;
use crate::util::emit_feedback_auth_recovery_tags;
use crate::util::emit_feedback_request_tags_with_auth_env;
@@ -261,6 +262,7 @@ impl ModelClient {
include_timing_metrics: bool,
beta_features_header: Option<String>,
) -> Self {
let auth_manager = auth_manager_for_provider(auth_manager, &provider);
let codex_api_key_env_enabled = auth_manager
.as_ref()
.is_some_and(|manager| manager.codex_api_key_env_enabled());
@@ -294,6 +296,10 @@ impl ModelClient {
}
}
pub(crate) fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.state.auth_manager.clone()
}
fn take_cached_websocket_session(&self) -> WebsocketSession {
let mut cached_websocket_session = self
.state
+6 -153
View File
@@ -1,10 +1,10 @@
use crate::client_common::tools::ToolSpec;
use crate::config::types::Personality;
use crate::error::Result;
pub use codex_api::common::ResponseEvent;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::ResponseItem;
use codex_tools::ToolSpec;
use futures::Stream;
use serde::Deserialize;
use serde_json::Value;
@@ -157,158 +157,11 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
}
pub(crate) mod tools {
use crate::tools::spec::JsonSchema;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
ImageGeneration { output_format: String },
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
// The `external_web_access` field determines whether the web search is over cached or live content.
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
#[serde(rename = "web_search")]
WebSearch {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
filters: Option<ResponsesApiWebSearchFilters>,
#[serde(skip_serializing_if = "Option::is_none")]
user_location: Option<ResponsesApiWebSearchUserLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
search_context_size: Option<WebSearchContextSize>,
#[serde(skip_serializing_if = "Option::is_none")]
search_content_types: Option<Vec<String>>,
},
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
impl ToolSpec {
pub(crate) fn name(&self) -> &str {
match self {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
ToolSpec::Freeform(tool) => tool.name.as_str(),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiWebSearchFilters {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) allowed_domains: Option<Vec<String>>,
}
impl From<ConfigWebSearchFilters> for ResponsesApiWebSearchFilters {
fn from(filters: ConfigWebSearchFilters) -> Self {
Self {
allowed_domains: filters.allowed_domains,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiWebSearchUserLocation {
#[serde(rename = "type")]
pub(crate) r#type: WebSearchUserLocationType,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) timezone: Option<String>,
}
impl From<ConfigWebSearchUserLocation> for ResponsesApiWebSearchUserLocation {
fn from(user_location: ConfigWebSearchUserLocation) -> Self {
Self {
r#type: user_location.r#type,
country: user_location.country,
region: user_location.region,
city: user_location.city,
timezone: user_location.timezone,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FreeformTool {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) format: FreeformToolFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FreeformToolFormat {
pub(crate) r#type: String,
pub(crate) syntax: String,
pub(crate) definition: String,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiTool {
pub(crate) name: String,
pub(crate) description: String,
/// TODO: Validation. When strict is set to true, the JSON schema,
/// `required` and `additional_properties` must be present. All fields in
/// `properties` must be present in `required`.
pub(crate) strict: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) defer_loading: Option<bool>,
pub(crate) parameters: JsonSchema,
#[serde(skip)]
pub(crate) output_schema: Option<Value>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ToolSearchOutputTool {
#[allow(dead_code)]
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiNamespace {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) tools: Vec<ResponsesApiNamespaceTool>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ResponsesApiNamespaceTool {
#[serde(rename = "function")]
Function(ResponsesApiTool),
}
pub(crate) use codex_tools::FreeformTool;
pub(crate) use codex_tools::FreeformToolFormat;
pub(crate) use codex_tools::ResponsesApiTool;
pub(crate) use codex_tools::ToolSearchOutputTool;
pub(crate) use codex_tools::ToolSpec;
}
pub struct ResponseStream {
+2 -47
View File
@@ -52,7 +52,8 @@ fn serializes_text_schema_with_strict_format() {
"required": ["answer"],
});
let text_controls =
create_text_param_for_request(None, &Some(schema.clone())).expect("text controls");
create_text_param_for_request(/*verbosity*/ None, &Some(schema.clone()))
.expect("text controls");
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
@@ -197,49 +198,3 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
]
);
}
#[test]
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace {
name: "mcp__codex_apps__calendar".to_string(),
description: "Plan events".to_string(),
tools: vec![tools::ResponsesApiNamespaceTool::Function(
tools::ResponsesApiTool {
name: "create_event".to_string(),
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
},
)],
});
let value = serde_json::to_value(namespace).expect("serialize namespace");
assert_eq!(
value,
serde_json::json!({
"type": "namespace",
"name": "mcp__codex_apps__calendar",
"description": "Plan events",
"tools": [
{
"type": "function",
"name": "create_event",
"description": "Create a calendar event.",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {}
}
}
]
})
);
}
+15 -10
View File
@@ -16,14 +16,14 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
crate::model_provider_info::WireApi::Responses,
);
ModelClient::new(
None,
/*auth_manager*/ None,
ThreadId::new(),
provider,
session_source,
None,
false,
false,
None,
/*model_verbosity*/ None,
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
)
}
@@ -62,11 +62,11 @@ fn test_session_telemetry() -> SessionTelemetry {
ThreadId::new(),
"gpt-test",
"gpt-test",
None,
None,
None,
/*account_id*/ None,
/*account_email*/ None,
/*auth_mode*/ None,
"test-originator".to_string(),
false,
/*log_user_prompts*/ false,
"test-terminal".to_string(),
SessionSource::Cli,
)
@@ -91,7 +91,12 @@ async fn summarize_memories_returns_empty_for_empty_input() {
let session_telemetry = test_session_telemetry();
let output = client
.summarize_memories(Vec::new(), &model_info, None, &session_telemetry)
.summarize_memories(
Vec::new(),
&model_info,
/*effort*/ None,
&session_telemetry,
)
.await
.expect("empty summarize request should succeed");
assert_eq!(output.len(), 0);
+139 -65
View File
@@ -11,7 +11,10 @@ use crate::CodexAuth;
use crate::SandboxState;
use crate::agent::AgentControl;
use crate::agent::AgentStatus;
use crate::agent::Mailbox;
use crate::agent::MailboxReceiver;
use crate::agent::agent_status_from_event;
use crate::agent::status::is_final;
use crate::apps::render_apps_section;
use crate::auth_env_telemetry::collect_auth_env_telemetry;
use crate::commit_attribution::commit_message_trailer_instruction;
@@ -37,6 +40,7 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
use crate::render_skills_section;
use crate::rollout::session_index;
use crate::session_prefix::format_subagent_notification_message;
use crate::skills_load_input_from_config;
use crate::stream_events_utils::HandleOutputCtx;
use crate::stream_events_utils::handle_non_tool_response_item;
@@ -93,6 +97,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::RawResponseItemEvent;
@@ -114,6 +119,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_terminal_detection::user_agent;
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_stream_parser::AssistantTextChunk;
use codex_utils_stream_parser::AssistantTextStreamParser;
@@ -819,7 +825,9 @@ 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>>,
mailbox: Mailbox,
mailbox_rx: Mutex<MailboxReceiver>,
idle_pending_input: Mutex<Vec<ResponseInputItem>>, // TODO (jif) merge with mailbox!
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
js_repl: Arc<JsReplHandle>,
@@ -1042,8 +1050,16 @@ impl TurnContext {
.network
.as_ref()?;
Some(TurnContextNetworkItem {
allowed_domains: network.allowed_domains.clone().unwrap_or_default(),
denied_domains: network.denied_domains.clone().unwrap_or_default(),
allowed_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
denied_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains)
.unwrap_or_default(),
})
}
}
@@ -1912,6 +1928,7 @@ impl Session {
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
watch::channel(false);
let (mailbox, mailbox_rx) = Mailbox::new();
let sess = Arc::new(Session {
conversation_id,
tx_event: tx_event.clone(),
@@ -1922,6 +1939,8 @@ impl Session {
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
mailbox,
mailbox_rx: Mutex::new(mailbox_rx),
idle_pending_input: Mutex::new(Vec::new()),
guardian_review_session: GuardianReviewSessionManager::default(),
services,
@@ -2639,6 +2658,8 @@ impl Session {
msg,
};
self.send_event_raw(event).await;
self.maybe_notify_parent_of_terminal_turn(turn_context, &legacy_source)
.await;
self.maybe_mirror_event_text_to_realtime(&legacy_source)
.await;
self.maybe_clear_realtime_handoff_for_event(&legacy_source)
@@ -2654,6 +2675,73 @@ impl Session {
}
}
/// Forwards terminal turn events from spawned MultiAgentV2 children to their direct parent.
async fn maybe_notify_parent_of_terminal_turn(
&self,
turn_context: &TurnContext,
msg: &EventMsg,
) {
if !self.enabled(Feature::MultiAgentV2) {
return;
}
if !matches!(msg, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) {
return;
}
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
agent_path: Some(child_agent_path),
..
}) = &turn_context.session_source
else {
return;
};
let Some(status) = agent_status_from_event(msg) else {
return;
};
if !is_final(&status) {
return;
}
self.forward_child_completion_to_parent(*parent_thread_id, child_agent_path, status)
.await;
}
/// Sends the standard completion envelope from a spawned MultiAgentV2 child to its parent.
async fn forward_child_completion_to_parent(
&self,
parent_thread_id: ThreadId,
child_agent_path: &codex_protocol::AgentPath,
status: AgentStatus,
) {
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| codex_protocol::AgentPath::try_from(parent).ok())
else {
return;
};
let message = format_subagent_notification_message(child_agent_path.as_str(), &status);
let communication = InterAgentCommunication::new(
child_agent_path.clone(),
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
if let Err(err) = self
.services
.agent_control
.send_inter_agent_communication(parent_thread_id, communication)
.await
{
debug!("failed to notify parent thread {parent_thread_id}: {err}");
}
}
async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) {
let Some(text) = realtime_text_for_event(msg) else {
return;
@@ -3967,6 +4055,18 @@ impl Session {
}
}
pub(crate) fn subscribe_mailbox_seq(&self) -> watch::Receiver<u64> {
self.mailbox.subscribe()
}
pub(crate) fn enqueue_mailbox_communication(&self, communication: InterAgentCommunication) {
self.mailbox.send(communication);
}
pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool {
self.mailbox_rx.lock().await.has_pending_trigger_turn()
}
pub async fn prepend_pending_input(&self, input: Vec<ResponseInputItem>) -> Result<(), ()> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
@@ -3980,28 +4080,37 @@ impl Session {
}
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.take_pending_input()
let pending_input = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.take_pending_input()
}
None => Vec::new(),
}
None => Vec::with_capacity(0),
}
}
pub(crate) async fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
let active = self.active_turn.lock().await;
match active.as_ref() {
Some(at) => {
let ts = at.turn_state.lock().await;
ts.pending_input_snapshot()
}
None => Vec::with_capacity(0),
};
let mailbox_items = {
let mut mailbox_rx = self.mailbox_rx.lock().await;
mailbox_rx
.drain()
.into_iter()
.map(|mail| mail.to_response_input_item())
.collect::<Vec<_>>()
};
if pending_input.is_empty() {
mailbox_items
} else if mailbox_items.is_empty() {
pending_input
} else {
let mut pending_input = pending_input;
pending_input.extend(mailbox_items);
pending_input
}
}
/// Queue response items to be injected into the next active turn created for this session.
#[cfg(test)]
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
if items.is_empty() {
return;
@@ -4015,17 +4124,14 @@ impl Session {
std::mem::take(&mut *self.idle_pending_input.lock().await)
}
pub(crate) async fn queued_response_items_for_next_turn_snapshot(
&self,
) -> Vec<ResponseInputItem> {
self.idle_pending_input.lock().await.clone()
}
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
!self.idle_pending_input.lock().await.is_empty()
}
pub async fn has_pending_input(&self) -> bool {
if self.mailbox_rx.lock().await.has_pending() {
return true;
}
let active = self.active_turn.lock().await;
match active.as_ref() {
Some(at) => {
@@ -4412,10 +4518,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::reload_user_config(&sess).await;
false
}
Op::ListCustomPrompts => {
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
false
}
Op::ListSkills { cwds, force_reload } => {
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
false
@@ -4541,13 +4643,11 @@ mod handlers {
use crate::tasks::UserShellCommandMode;
use crate::tasks::UserShellCommandTask;
use crate::tasks::execute_user_shell_command;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
use codex_protocol::protocol::ListSkillsResponseEvent;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
@@ -4690,25 +4790,17 @@ mod handlers {
}
}
/// Records an inter-agent assistant envelope and, when requested, wakes the recipient by
/// starting a regular turn if the session is currently idle.
/// Records an inter-agent assistant envelope, then lets the shared pending-work scheduler
/// decide whether an idle session should start a regular turn.
pub async fn inter_agent_communication(
sess: &Arc<Session>,
sub_id: String,
communication: InterAgentCommunication,
) {
let pending_item = communication.to_response_input_item();
if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await {
return;
}
sess.queue_response_items_for_next_turn(vec![pending_item])
.await;
if communication.trigger_turn {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
let trigger_turn = communication.trigger_turn;
sess.enqueue_mailbox_communication(communication);
if trigger_turn {
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
.await;
}
}
@@ -4934,23 +5026,6 @@ mod handlers {
sess.send_event_raw(event).await;
}
pub async fn list_custom_prompts(sess: &Session, sub_id: String) {
let custom_prompts: Vec<CustomPrompt> =
if let Some(dir) = crate::custom_prompts::default_prompts_dir() {
crate::custom_prompts::discover_prompts_in(&dir).await
} else {
Vec::new()
};
let event = Event {
id: sub_id,
msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent {
custom_prompts,
}),
};
sess.send_event_raw(event).await;
}
pub async fn list_skills(
sess: &Session,
sub_id: String,
@@ -6592,7 +6667,7 @@ pub(crate) async fn built_tools(
)
.await
.map(|discoverable_tools| {
crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client(
filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools,
turn_context.app_server_client_name.as_deref(),
)
@@ -6903,7 +6978,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::TurnDiff(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::PlanUpdate(_)
@@ -41,7 +41,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem {
AgentPath::root().join("worker").unwrap(),
Vec::new(),
text.to_string(),
true,
/*trigger_turn*/ true,
);
ResponseItem::Message {
id: None,
+121 -69
View File
@@ -5,6 +5,8 @@ use crate::config::test_config;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::NetworkConstraints;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::exec::ExecCapturePolicy;
@@ -245,17 +247,17 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted
fn test_model_client_session() -> crate::client::ModelClientSession {
crate::client::ModelClient::new(
None,
/*auth_manager*/ None,
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
.expect("test thread id should be valid"),
crate::model_provider_info::ModelProviderInfo::create_openai_provider(
/* base_url */ None,
/* base_url */ /*base_url*/ None,
),
codex_protocol::protocol::SessionSource::Exec,
None,
false,
false,
None,
/*model_verbosity*/ None,
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
)
.new_session()
}
@@ -311,7 +313,7 @@ fn make_connector(id: &str, name: &str) -> AppInfo {
#[test]
fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() {
let mut parsers = AssistantMessageStreamParsers::new(false);
let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false);
let item_id = "msg-1";
let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-citation>doc");
@@ -328,7 +330,7 @@ fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text()
#[test]
fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() {
let mut parsers = AssistantMessageStreamParsers::new(false);
let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false);
let item_id = "msg-1";
let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-");
@@ -345,7 +347,7 @@ fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tai
#[test]
fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boundaries() {
let mut parsers = AssistantMessageStreamParsers::new(true);
let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ true);
let item_id = "msg-1";
let seeded = parsers.seed_item_text(item_id, "Intro\n<proposed");
@@ -447,7 +449,7 @@ fn validated_network_policy_amendment_host_rejects_mismatch() {
async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> {
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
None,
/*requirements*/ None,
&SandboxPolicy::new_workspace_write_policy(),
)?;
let mut exec_policy = Policy::empty();
@@ -455,24 +457,24 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho
"example.com",
NetworkRuleProtocol::Https,
Decision::Allow,
None,
/*justification*/ None,
)?;
let (started_proxy, _) = Session::start_managed_network_proxy(
&spec,
&exec_policy,
&SandboxPolicy::new_workspace_write_policy(),
None,
None,
false,
/*network_policy_decider*/ None,
/*blocked_request_observer*/ None,
/*managed_network_requirements_enabled*/ false,
crate::config::NetworkProxyAuditMetadata::default(),
)
.await?;
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(
current_cfg.network.allowed_domains,
vec!["example.com".to_string()]
current_cfg.network.allowed_domains(),
Some(vec!["example.com".to_string()])
);
Ok(())
}
@@ -483,7 +485,12 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
Some(NetworkConstraints {
allowed_domains: Some(vec!["managed.example.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([(
"managed.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
managed_allowed_domains_only: Some(true),
..Default::default()
}),
@@ -494,24 +501,24 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
"example.com",
NetworkRuleProtocol::Https,
Decision::Allow,
None,
/*justification*/ None,
)?;
let (started_proxy, _) = Session::start_managed_network_proxy(
&spec,
&exec_policy,
&SandboxPolicy::new_workspace_write_policy(),
None,
None,
false,
/*network_policy_decider*/ None,
/*blocked_request_observer*/ None,
/*managed_network_requirements_enabled*/ false,
crate::config::NetworkProxyAuditMetadata::default(),
)
.await?;
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(
current_cfg.network.allowed_domains,
vec!["managed.example.com".to_string()]
current_cfg.network.allowed_domains(),
Some(vec!["managed.example.com".to_string()])
);
Ok(())
}
@@ -730,7 +737,9 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() {
),
(
"mcp__rmcp__echo".to_string(),
make_mcp_tool("rmcp", "echo", None, None),
make_mcp_tool(
"rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None,
),
),
]);
@@ -778,7 +787,9 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
),
(
"mcp__rmcp__echo".to_string(),
make_mcp_tool("rmcp", "echo", None, None),
make_mcp_tool(
"rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None,
),
),
]);
@@ -828,7 +839,9 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
),
(
"mcp__rmcp__echo".to_string(),
make_mcp_tool("rmcp", "echo", None, None),
make_mcp_tool(
"rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None,
),
),
]);
@@ -1172,7 +1185,13 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted);
let forked = initial
.thread_manager
.fork_thread(usize::MAX, fork_config, rollout_path, false, None)
.fork_thread(
usize::MAX,
fork_config,
rollout_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await?;
let collaboration_mode = CollaborationMode {
@@ -1344,7 +1363,7 @@ async fn thread_rollback_drops_last_turn_from_history() {
state.set_reference_context_item(Some(tc.to_turn_context_item()));
}
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let rollback_event = wait_for_thread_rolled_back(&rx).await;
assert_eq!(rollback_event.num_turns, 1);
@@ -1391,7 +1410,7 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns()
.collect();
sess.persist_rollout_items(&rollout_items).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 99).await;
let rollback_event = wait_for_thread_rolled_back(&rx).await;
assert_eq!(rollback_event.num_turns, 99);
@@ -1408,7 +1427,7 @@ async fn thread_rollback_fails_without_persisted_rollout_path() {
sess.record_into_history(&initial_context, tc.as_ref())
.await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let error_event = wait_for_thread_rollback_failed(&rx).await;
assert_eq!(
@@ -1502,7 +1521,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
}))
.await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let rollback_event = wait_for_thread_rolled_back(&rx).await;
assert_eq!(rollback_event.num_turns, 1);
@@ -1610,7 +1629,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
)
.await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let rollback_event = wait_for_thread_rolled_back(&rx).await;
assert_eq!(rollback_event.num_turns, 1);
@@ -1688,10 +1707,10 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
])
.await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let first_rollback = wait_for_thread_rolled_back(&rx).await;
assert_eq!(first_rollback.num_turns, 1);
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let second_rollback = wait_for_thread_rolled_back(&rx).await;
assert_eq!(second_rollback.num_turns, 1);
@@ -1726,7 +1745,7 @@ async fn thread_rollback_fails_when_turn_in_progress() {
.await;
*sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let error_event = wait_for_thread_rollback_failed(&rx).await;
assert_eq!(
@@ -1746,7 +1765,7 @@ async fn thread_rollback_fails_when_num_turns_is_zero() {
sess.record_into_history(&initial_context, tc.as_ref())
.await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 0).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 0).await;
let error_event = wait_for_thread_rollback_failed(&rx).await;
assert_eq!(error_event.message, "num_turns must be >= 1");
@@ -2145,14 +2164,14 @@ async fn attach_rollout_recorder(session: &Arc<Session>) -> PathBuf {
config.as_ref(),
RolloutRecorderParams::new(
ThreadId::default(),
None,
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
None,
None,
/*state_db_ctx*/ None,
/*state_builder*/ None,
)
.await
.expect("create rollout recorder");
@@ -2191,11 +2210,11 @@ fn session_telemetry(
conversation_id,
ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(),
model_info.slug.as_str(),
None,
/*account_id*/ None,
Some("test@test.com".to_string()),
Some(TelemetryAuthMode::Chatgpt),
"test_originator".to_string(),
false,
/*log_user_prompts*/ false,
"test".to_string(),
session_source,
)
@@ -2318,7 +2337,7 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
.skills_manager
.skills_for_cwd(
&crate::skills_load_input_from_config(&parent_config, Vec::new()),
true,
/*force_reload*/ true,
)
.await;
let parent_skill = parent_outcome
@@ -2466,7 +2485,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
None,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
));
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
@@ -2515,7 +2534,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
/*bundled_skills_enabled*/ true,
));
let result = Session::new(
session_configuration,
Arc::clone(&config),
@@ -2556,7 +2578,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
None,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
));
let agent_control = AgentControl::default();
@@ -2619,7 +2641,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let state = SessionState::new(session_configuration.clone());
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
/*bundled_skills_enabled*/ true,
));
let network_approval = Arc::new(NetworkApprovalService::default());
let environment = Arc::new(
codex_exec_server::Environment::create(/*exec_server_url*/ None)
@@ -2705,13 +2730,14 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
per_turn_config,
model_info,
&models_manager,
None,
/*network*/ None,
environment,
"turn_id".to_string(),
Arc::clone(&js_repl),
skills_outcome,
);
let (mailbox, mailbox_rx) = crate::agent::Mailbox::new();
let session = Session {
conversation_id,
tx_event,
@@ -2722,6 +2748,8 @@ 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),
mailbox,
mailbox_rx: Mutex::new(mailbox_rx),
idle_pending_input: Mutex::new(Vec::new()),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
@@ -3393,7 +3421,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
None,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
));
let agent_control = AgentControl::default();
@@ -3456,7 +3484,10 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
let state = SessionState::new(session_configuration.clone());
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
/*bundled_skills_enabled*/ true,
));
let network_approval = Arc::new(NetworkApprovalService::default());
let environment = Arc::new(
codex_exec_server::Environment::create(/*exec_server_url*/ None)
@@ -3542,13 +3573,14 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
per_turn_config,
model_info,
&models_manager,
None,
/*network*/ None,
environment,
"turn_id".to_string(),
Arc::clone(&js_repl),
skills_outcome,
));
let (mailbox, mailbox_rx) = crate::agent::Mailbox::new();
let session = Arc::new(Session {
conversation_id,
tx_event,
@@ -3559,6 +3591,8 @@ 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),
mailbox,
mailbox_rx: Mutex::new(mailbox_rx),
idle_pending_input: Mutex::new(Vec::new()),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
@@ -3652,7 +3686,8 @@ async fn record_model_warning_appends_user_message() {
#[tokio::test]
async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
sess.set_previous_turn_settings(None).await;
sess.set_previous_turn_settings(/*previous_turn_settings*/ None)
.await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
@@ -3687,15 +3722,28 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
let mut requirements = config.config_layer_stack.requirements().clone();
requirements.network = Some(Sourced::new(
NetworkConstraints {
allowed_domains: Some(vec!["api.example.com".to_string()]),
denied_domains: Some(vec!["blocked.example.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([
(
"api.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"blocked.example.com".to_string(),
NetworkDomainPermissionToml::Deny,
),
]),
}),
..Default::default()
},
RequirementSource::CloudRequirements,
));
let layers = config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect();
@@ -3892,7 +3940,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist
revised_prompt: Some("a tiny blue square".to_string()),
result: "Zm9v".to_string(),
}],
None,
/*reference_context_item*/ None,
)
.await;
@@ -3946,7 +3994,7 @@ async fn handle_output_item_done_records_image_save_history_message() {
tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)),
cancellation_token: CancellationToken::new(),
};
handle_output_item_done(&mut ctx, item.clone(), None)
handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None)
.await
.expect("image generation item should succeed");
@@ -4003,7 +4051,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() {
tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)),
cancellation_token: CancellationToken::new(),
};
handle_output_item_done(&mut ctx, item.clone(), None)
handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None)
.await
.expect("image generation item should still complete");
@@ -4095,10 +4143,13 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co
.await;
{
let mut state = session.state.lock().await;
state.set_reference_context_item(None);
state.set_reference_context_item(/*item*/ None);
}
session
.replace_history(vec![compacted_summary.clone()], None)
.replace_history(
vec![compacted_summary.clone()],
/*reference_context_item*/ None,
)
.await;
session
@@ -4133,14 +4184,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
config.as_ref(),
RolloutRecorderParams::new(
ThreadId::default(),
None,
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
None,
None,
/*state_db_ctx*/ None,
/*state_builder*/ None,
)
.await
.expect("create rollout recorder");
@@ -4230,14 +4281,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
config.as_ref(),
RolloutRecorderParams::new(
ThreadId::default(),
None,
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
None,
None,
/*state_db_ctx*/ None,
/*state_builder*/ None,
)
.await
.expect("create rollout recorder");
@@ -4259,7 +4310,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
.await;
{
let mut state = session.state.lock().await;
state.set_reference_context_item(None);
state.set_reference_context_item(/*item*/ None);
}
session
@@ -4298,7 +4349,7 @@ async fn run_user_shell_command_does_not_set_reference_context_item() {
let (session, _turn_context, rx) = make_session_and_context_with_rx().await;
{
let mut state = session.state.lock().await;
state.set_reference_context_item(None);
state.set_reference_context_item(/*item*/ None);
}
handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string())
@@ -4451,7 +4502,8 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input()
.await
.expect("inject pending input into active turn");
sess.on_task_finished(Arc::clone(&tc), None).await;
sess.on_task_finished(Arc::clone(&tc), /*last_agent_message*/ None)
.await;
let history = sess.clone_history().await;
let expected = ResponseItem::Message {
@@ -4543,7 +4595,7 @@ async fn steer_input_requires_active_turn() {
}];
let err = sess
.steer_input(input, None)
.steer_input(input, /*expected_turn_id*/ None)
.await
.expect_err("steering without active turn should fail");
+5 -2
View File
@@ -425,11 +425,14 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
None,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
));
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
/*bundled_skills_enabled*/ true,
));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_watcher = Arc::new(SkillsWatcher::noop());
+1 -4
View File
@@ -180,10 +180,7 @@ impl CodexThread {
.session
.queue_response_items_for_next_turn(items)
.await;
self.codex
.session
.ensure_task_for_queued_response_items()
.await;
self.codex.session.maybe_start_turn_for_pending_work().await;
}
Ok(submission_id)
@@ -11,7 +11,7 @@ fn blank_attribution_disables_trailer_prompt() {
#[test]
fn default_attribution_uses_codex_trailer() {
assert_eq!(
build_commit_message_trailer(None).as_deref(),
build_commit_message_trailer(/*config_attribution*/ None).as_deref(),
Some("Co-authored-by: Codex <noreply@openai.com>")
);
}
@@ -19,7 +19,7 @@ fn default_attribution_uses_codex_trailer() {
#[test]
fn resolve_value_handles_default_custom_and_blank() {
assert_eq!(
resolve_attribution_value(None),
resolve_attribution_value(/*config_attribution*/ None),
Some("Codex <noreply@openai.com>".to_string())
);
assert_eq!(
+20 -8
View File
@@ -216,8 +216,11 @@ async fn process_compacted_history_replaces_developer_messages() {
phase: None,
},
];
let (refreshed, mut expected) =
process_compacted_history_with_test_session(compacted_history, None).await;
let (refreshed, mut expected) = process_compacted_history_with_test_session(
compacted_history,
/*previous_turn_settings*/ None,
)
.await;
expected.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -241,8 +244,11 @@ async fn process_compacted_history_reinjects_full_initial_context() {
end_turn: None,
phase: None,
}];
let (refreshed, mut expected) =
process_compacted_history_with_test_session(compacted_history, None).await;
let (refreshed, mut expected) = process_compacted_history_with_test_session(
compacted_history,
/*previous_turn_settings*/ None,
)
.await;
expected.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -317,8 +323,11 @@ keep me updated
phase: None,
},
];
let (refreshed, mut expected) =
process_compacted_history_with_test_session(compacted_history, None).await;
let (refreshed, mut expected) = process_compacted_history_with_test_session(
compacted_history,
/*previous_turn_settings*/ None,
)
.await;
expected.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -363,8 +372,11 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message
},
];
let (refreshed, initial_context) =
process_compacted_history_with_test_session(compacted_history, None).await;
let (refreshed, initial_context) = process_compacted_history_with_test_session(
compacted_history,
/*previous_turn_settings*/ None,
)
.await;
let mut expected = vec![
ResponseItem::Message {
id: None,
+108 -52
View File
@@ -243,6 +243,26 @@ web_search = false
);
}
#[test]
fn rejects_provider_auth_with_env_key() {
let err = toml::from_str::<ConfigToml>(
r#"
[model_providers.corp]
name = "Corp"
env_key = "CORP_TOKEN"
[model_providers.corp.auth]
command = "print-token"
"#,
)
.unwrap_err();
assert!(
err.to_string()
.contains("model_providers.corp: provider auth cannot be combined with env_key")
);
}
#[test]
fn config_toml_deserializes_model_availability_nux() {
let toml = r#"
@@ -306,7 +326,9 @@ enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
allow_upstream_proxy = false
allowed_domains = ["openai.com"]
[permissions.workspace.network.domains]
"openai.com" = "allow"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
@@ -343,9 +365,13 @@ allowed_domains = ["openai.com"]
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
allowed_domains: Some(vec!["openai.com".to_string()]),
denied_domains: None,
allow_unix_sockets: None,
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
unix_sockets: None,
allow_local_binding: None,
}),
},
@@ -421,7 +447,12 @@ fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> st
)]),
}),
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}),
},
@@ -496,7 +527,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(None),
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -698,7 +729,10 @@ fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
config.permissions.file_system_sandbox_policy,
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::unknown(":future_special_path", None),
value: FileSystemSpecialPath::unknown(
":future_special_path",
/*subpath*/ None
),
},
access: FileSystemAccessMode::Read,
}]),
@@ -965,10 +999,10 @@ network_access = false # This should be ignored.
let sandbox_mode_override = None;
let resolution = sandbox_full_access_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
None,
/*sandbox_policy_constraint*/ None,
);
assert_eq!(resolution, SandboxPolicy::DangerFullAccess);
@@ -984,10 +1018,10 @@ network_access = true # This should be ignored.
let sandbox_mode_override = None;
let resolution = sandbox_read_only_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
None,
/*sandbox_policy_constraint*/ None,
);
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
@@ -1011,10 +1045,10 @@ exclude_slash_tmp = true
let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
None,
/*sandbox_policy_constraint*/ None,
);
if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
@@ -1053,10 +1087,10 @@ trust_level = "trusted"
let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
None,
/*sandbox_policy_constraint*/ None,
);
if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
@@ -1234,7 +1268,7 @@ fn filter_mcp_servers_by_allowlist_allows_all_when_unset() {
("server-b".to_string(), http_mcp("https://example.com/b")),
]);
filter_mcp_servers_by_requirements(&mut servers, None);
filter_mcp_servers_by_requirements(&mut servers, /*mcp_requirements*/ None);
assert_eq!(
servers
@@ -1863,7 +1897,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -1893,7 +1927,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
let empty = BTreeMap::new();
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(empty.clone())],
)?;
let loaded = load_global_mcp_servers(codex_home.path()).await?;
@@ -2090,7 +2124,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2163,7 +2197,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2216,7 +2250,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2267,7 +2301,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2333,7 +2367,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
)]);
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2413,7 +2447,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let serialized_with_optional = std::fs::read_to_string(&config_path)?;
@@ -2444,7 +2478,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
);
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2535,7 +2569,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2619,7 +2653,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2666,7 +2700,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2713,7 +2747,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2764,7 +2798,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
apply_blocking(
codex_home.path(),
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
@@ -2918,7 +2952,7 @@ async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> {
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", true)
.set_feature_enabled("guardian_approval", /*enabled*/ true)
.apply()
.await?;
@@ -2954,13 +2988,13 @@ async fn set_feature_enabled_persists_default_false_feature_disable_in_profile()
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", true)
.set_feature_enabled("guardian_approval", /*enabled*/ true)
.apply()
.await?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", false)
.set_feature_enabled("guardian_approval", /*enabled*/ false)
.apply()
.await?;
@@ -2994,13 +3028,13 @@ async fn set_feature_enabled_profile_disable_overrides_root_enable() -> anyhow::
let codex_home = TempDir::new()?;
ConfigEditsBuilder::new(codex_home.path())
.set_feature_enabled("guardian_approval", true)
.set_feature_enabled("guardian_approval", /*enabled*/ true)
.apply()
.await?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", false)
.set_feature_enabled("guardian_approval", /*enabled*/ false)
.apply()
.await?;
@@ -4301,6 +4335,7 @@ model_verbosity = "high"
wire_api: crate::WireApi::Responses,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
query_params: None,
http_headers: None,
env_http_headers: None,
@@ -4312,7 +4347,8 @@ model_verbosity = "high"
supports_websockets: false,
};
let model_provider_map = {
let mut model_provider_map = built_in_model_providers(/* openai_base_url */ None);
let mut model_provider_map =
built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None);
model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone());
model_provider_map
};
@@ -4381,7 +4417,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
windows_sandbox_private_desktop: true,
},
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(None),
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -4523,7 +4559,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
windows_sandbox_private_desktop: true,
},
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(None),
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -4663,7 +4699,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
windows_sandbox_private_desktop: true,
},
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(None),
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -4789,7 +4825,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
windows_sandbox_private_desktop: true,
},
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(None),
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -5131,11 +5167,11 @@ trust_level = "untrusted"
.expect("TOML deserialization should succeed");
let resolution = cfg.derive_sandbox_policy(
None,
None,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
None,
/*sandbox_policy_constraint*/ None,
);
// Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade)
@@ -5183,8 +5219,8 @@ fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defaults()
})?;
let resolution = cfg.derive_sandbox_policy(
None,
None,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&project_path,
Some(&constrained),
@@ -5223,8 +5259,8 @@ fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallback()
})?;
let resolution = cfg.derive_sandbox_policy(
None,
None,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&project_path,
Some(&constrained),
@@ -5241,7 +5277,11 @@ fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallback()
#[test]
fn test_resolve_oss_provider_explicit_override() {
let config_toml = ConfigToml::default();
let result = resolve_oss_provider(Some("custom-provider"), &config_toml, None);
let result = resolve_oss_provider(
Some("custom-provider"),
&config_toml,
/*config_profile*/ None,
);
assert_eq!(result, Some("custom-provider".to_string()));
}
@@ -5258,7 +5298,11 @@ fn test_resolve_oss_provider_from_profile() {
..Default::default()
};
let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string()));
let result = resolve_oss_provider(
/*explicit_provider*/ None,
&config_toml,
Some("test-profile".to_string()),
);
assert_eq!(result, Some("profile-provider".to_string()));
}
@@ -5269,7 +5313,11 @@ fn test_resolve_oss_provider_from_global_config() {
..Default::default()
};
let result = resolve_oss_provider(None, &config_toml, None);
let result = resolve_oss_provider(
/*explicit_provider*/ None,
&config_toml,
/*config_profile*/ None,
);
assert_eq!(result, Some("global-provider".to_string()));
}
@@ -5284,14 +5332,22 @@ fn test_resolve_oss_provider_profile_fallback_to_global() {
..Default::default()
};
let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string()));
let result = resolve_oss_provider(
/*explicit_provider*/ None,
&config_toml,
Some("test-profile".to_string()),
);
assert_eq!(result, Some("global-provider".to_string()));
}
#[test]
fn test_resolve_oss_provider_none_when_not_configured() {
let config_toml = ConfigToml::default();
let result = resolve_oss_provider(None, &config_toml, None);
let result = resolve_oss_provider(
/*explicit_provider*/ None,
&config_toml,
/*config_profile*/ None,
);
assert_eq!(result, None);
}
+48 -23
View File
@@ -16,7 +16,7 @@ fn blocking_set_model_top_level() {
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.1-codex".to_string()),
effort: Some(ReasoningEffort::High),
@@ -150,7 +150,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("o4-mini".to_string()),
effort: None,
@@ -195,7 +195,7 @@ fn blocking_set_model_writes_through_symlink_chain() {
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.1-codex".to_string()),
effort: Some(ReasoningEffort::High),
@@ -228,7 +228,7 @@ fn blocking_set_model_replaces_symlink_on_cycle() {
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.1-codex".to_string()),
effort: None,
@@ -267,7 +267,7 @@ network_access = false
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[
ConfigEdit::SetPath {
segments: vec![
@@ -322,7 +322,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: None,
effort: Some(ReasoningEffort::High),
@@ -356,7 +356,7 @@ model_reasoning_effort = "low"
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("o5-preview".to_string()),
effort: Some(ReasoningEffort::Minimal),
@@ -420,7 +420,7 @@ existing = "value"
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideFullAccessWarning(true)],
)
.expect("persist");
@@ -450,7 +450,7 @@ existing = "value"
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)],
)
.expect("persist");
@@ -476,7 +476,7 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
"hide_gpt5_1_migration_prompt".to_string(),
true,
@@ -505,7 +505,7 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
"hide_gpt-5.1-codex-max_migration_prompt".to_string(),
true,
@@ -534,7 +534,7 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::RecordModelMigrationSeen {
from: "gpt-5".to_string(),
to: "gpt-5.1".to_string(),
@@ -616,7 +616,7 @@ fn blocking_replace_mcp_servers_round_trips() {
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)
.expect("persist");
@@ -681,7 +681,12 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
},
);
apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = "\
@@ -731,7 +736,12 @@ foo = { command = "cmd" }
},
);
apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[mcp_servers]
@@ -777,7 +787,12 @@ foo = { command = "cmd" } # keep me
},
);
apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[mcp_servers]
@@ -822,7 +837,12 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
},
);
apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[mcp_servers]
@@ -868,7 +888,12 @@ foo = { command = "cmd" }
},
);
apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[mcp_servers]
@@ -885,7 +910,7 @@ fn blocking_clear_path_noop_when_missing() {
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::ClearPath {
segments: vec!["missing".to_string()],
}],
@@ -906,7 +931,7 @@ fn blocking_set_path_updates_notifications() {
let item = value(false);
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "notifications".to_string()],
value: item,
@@ -982,7 +1007,7 @@ async fn blocking_set_asynchronous_helpers_available() {
let codex_home = tmp.path().to_path_buf();
ConfigEditsBuilder::new(&codex_home)
.set_hide_full_access_warning(true)
.set_hide_full_access_warning(/*acknowledged*/ true)
.apply()
.await
.expect("persist");
@@ -1024,7 +1049,7 @@ fn blocking_builder_set_realtime_audio_persists_and_clears() {
);
ConfigEditsBuilder::new(codex_home)
.set_realtime_microphone(None)
.set_realtime_microphone(/*microphone*/ None)
.apply_blocking()
.expect("clear realtime microphone");
@@ -1053,7 +1078,7 @@ fn replace_mcp_servers_blocking_clears_table_when_empty() {
apply_blocking(
codex_home,
None,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(BTreeMap::new())],
)
.expect("persist");
+4 -1
View File
@@ -96,7 +96,10 @@ impl ManagedFeatures {
impl From<Features> for ManagedFeatures {
fn from(features: Features) -> Self {
Self {
value: ConstrainedWithSource::new(Constrained::allow_any(features), None),
value: ConstrainedWithSource::new(
Constrained::allow_any(features),
/*source*/ None,
),
pinned_features: BTreeMap::new(),
}
}
+19 -2
View File
@@ -123,9 +123,14 @@ pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::FilesystemPermissionToml;
pub use permissions::FilesystemPermissionsToml;
pub use permissions::NetworkDomainPermissionToml;
pub use permissions::NetworkDomainPermissionsToml;
pub use permissions::NetworkToml;
pub use permissions::NetworkUnixSocketPermissionToml;
pub use permissions::NetworkUnixSocketPermissionsToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
pub(crate) use permissions::overlay_network_domain_permissions;
pub(crate) use permissions::resolve_permission_profile;
pub use service::ConfigService;
pub use service::ConfigServiceError;
@@ -1832,6 +1837,18 @@ Built-in providers cannot be overridden. Rename your custom provider (for exampl
}
}
fn validate_model_providers(
model_providers: &HashMap<String, ModelProviderInfo>,
) -> Result<(), String> {
validate_reserved_model_provider_ids(model_providers)?;
for (key, provider) in model_providers {
provider
.validate()
.map_err(|message| format!("model_providers.{key}: {message}"))?;
}
Ok(())
}
fn deserialize_model_providers<'de, D>(
deserializer: D,
) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
@@ -1839,7 +1856,7 @@ where
D: serde::Deserializer<'de>,
{
let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?;
validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?;
Ok(model_providers)
}
@@ -1964,7 +1981,7 @@ impl Config {
codex_home: PathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
validate_reserved_model_provider_ids(&cfg.model_providers)
validate_model_providers(&cfg.model_providers)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
+56 -38
View File
@@ -226,33 +226,63 @@ impl NetworkProxySpec {
Some(dangerously_allow_all_unix_sockets);
}
let managed_allowed_domains = if hard_deny_allowlist_misses {
Some(requirements.allowed_domains.clone().unwrap_or_default())
Some(
requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
)
} else {
requirements.allowed_domains.clone()
requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
};
if let Some(allowed_domains) = managed_allowed_domains {
if let Some(managed_allowed_domains) = managed_allowed_domains {
// Managed requirements seed the baseline allowlist. User additions
// can extend that baseline unless managed-only mode pins the
// effective allowlist to the managed set.
config.network.allowed_domains = if allowlist_expansion_enabled {
Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains)
let effective_allowed_domains = if allowlist_expansion_enabled {
Self::merge_domain_lists(
managed_allowed_domains.clone(),
config.network.allowed_domains().as_deref().unwrap_or(&[]),
)
} else {
allowed_domains.clone()
managed_allowed_domains.clone()
};
constraints.allowed_domains = Some(allowed_domains);
config
.network
.set_allowed_domains(effective_allowed_domains);
constraints.allowed_domains = Some(managed_allowed_domains);
constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled);
}
if let Some(denied_domains) = requirements.denied_domains.clone() {
config.network.denied_domains = if denylist_expansion_enabled {
Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains)
let managed_denied_domains = requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
if let Some(managed_denied_domains) = managed_denied_domains {
let effective_denied_domains = if denylist_expansion_enabled {
Self::merge_domain_lists(
managed_denied_domains.clone(),
config.network.denied_domains().as_deref().unwrap_or(&[]),
)
} else {
denied_domains.clone()
managed_denied_domains.clone()
};
constraints.denied_domains = Some(denied_domains);
config.network.set_denied_domains(effective_denied_domains);
constraints.denied_domains = Some(managed_denied_domains);
constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled);
}
if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() {
config.network.allow_unix_sockets = allow_unix_sockets.clone();
if requirements.unix_sockets.is_some() {
let allow_unix_sockets = requirements
.unix_sockets
.as_ref()
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.unwrap_or_default();
config
.network
.set_allow_unix_sockets(allow_unix_sockets.clone());
constraints.allow_unix_sockets = Some(allow_unix_sockets);
}
if let Some(allow_local_binding) = requirements.allow_local_binding {
@@ -299,37 +329,25 @@ impl NetworkProxySpec {
fn apply_exec_policy_network_rules(config: &mut NetworkProxyConfig, exec_policy: &Policy) {
let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains();
upsert_network_domains(
&mut config.network.allowed_domains,
&mut config.network.denied_domains,
allowed_domains,
);
upsert_network_domains(
&mut config.network.denied_domains,
&mut config.network.allowed_domains,
denied_domains,
);
upsert_network_domains(config, allowed_domains, /*allow*/ true);
upsert_network_domains(config, denied_domains, /*allow*/ false);
}
fn upsert_network_domains(
target: &mut Vec<String>,
opposite: &mut Vec<String>,
hosts: Vec<String>,
) {
fn upsert_network_domains(config: &mut NetworkProxyConfig, hosts: Vec<String>, allow: bool) {
let mut incoming = HashSet::new();
let mut deduped_hosts = Vec::new();
for host in hosts {
if incoming.insert(host.clone()) {
deduped_hosts.push(host);
config.network.upsert_domain_permission(
host,
if allow {
codex_network_proxy::NetworkDomainPermission::Allow
} else {
codex_network_proxy::NetworkDomainPermission::Deny
},
normalize_host,
);
}
}
if incoming.is_empty() {
return;
}
opposite.retain(|entry| !incoming.contains(&normalize_host(entry)));
target.retain(|entry| !incoming.contains(&normalize_host(entry)));
target.extend(deduped_hosts);
}
#[cfg(test)]
@@ -1,6 +1,20 @@
use super::*;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use codex_network_proxy::NetworkDomainPermission;
use pretty_assertions::assert_eq;
fn domain_permissions(
entries: impl IntoIterator<Item = (&'static str, NetworkDomainPermissionToml)>,
) -> NetworkDomainPermissionsToml {
NetworkDomainPermissionsToml {
entries: entries
.into_iter()
.map(|(pattern, permission)| (pattern.to_string(), permission))
.collect(),
}
}
#[test]
fn build_state_with_audit_metadata_threads_metadata_to_state() {
let spec = NetworkProxySpec {
@@ -24,9 +38,14 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() {
#[test]
fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
domains: Some(domain_permissions([(
"*.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
@@ -38,8 +57,11 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
.expect("config should stay within the managed allowlist");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string(), "api.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec![
"*.example.com".to_string(),
"api.example.com".to_string()
])
);
assert_eq!(
spec.constraints.allowed_domains,
@@ -48,14 +70,92 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true));
}
#[test]
fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_denied_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"api.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed allowlist should not erase a user deny");
assert_eq!(spec.config.network.allowed_domains(), None);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec!["api.example.com".to_string()])
);
assert_eq!(
spec.constraints.allowed_domains,
Some(vec!["api.example.com".to_string()])
);
}
#[test]
fn requirements_allowlist_expansion_keeps_user_entries_mutable() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"*.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed baseline should still allow user edits");
let mut candidate = spec.config.clone();
candidate.network.upsert_domain_permission(
"api.example.com".to_string(),
NetworkDomainPermission::Deny,
normalize_host,
);
assert_eq!(
candidate.network.allowed_domains(),
Some(vec!["*.example.com".to_string()])
);
assert_eq!(
candidate.network.denied_domains(),
Some(vec!["api.example.com".to_string()])
);
validate_policy_against_constraints(&candidate, &spec.constraints)
.expect("user allowlist entries should not become managed constraints");
}
#[test]
fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["evil.com".to_string()];
config.network.denied_domains = vec!["more-blocked.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["evil.com".to_string()]);
config
.network
.set_denied_domains(vec!["more-blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
denied_domains: Some(vec!["blocked.example.com".to_string()]),
domains: Some(domain_permissions([
("*.example.com", NetworkDomainPermissionToml::Allow),
("blocked.example.com", NetworkDomainPermissionToml::Deny),
])),
..Default::default()
};
@@ -67,12 +167,12 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
.expect("yolo mode should pin the effective policy to the managed baseline");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec!["*.example.com".to_string()])
);
assert_eq!(
spec.config.network.denied_domains,
vec!["blocked.example.com".to_string()]
spec.config.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false));
@@ -81,9 +181,14 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
#[test]
fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
domains: Some(domain_permissions([(
"*.example.com",
NetworkDomainPermissionToml::Allow,
)])),
managed_allowed_domains_only: Some(true),
..Default::default()
};
@@ -96,8 +201,8 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
.expect("managed baseline should still load");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec!["*.example.com".to_string()])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
}
@@ -105,9 +210,14 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
#[test]
fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["managed.example.com".to_string()]),
domains: Some(domain_permissions([(
"managed.example.com",
NetworkDomainPermissionToml::Allow,
)])),
managed_allowed_domains_only: Some(true),
..Default::default()
};
@@ -120,8 +230,8 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
.expect("managed-only allowlist should still load");
assert_eq!(
spec.config.network.allowed_domains,
vec!["managed.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec!["managed.example.com".to_string()])
);
assert_eq!(
spec.constraints.allowed_domains,
@@ -134,7 +244,9 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
#[test]
fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
managed_allowed_domains_only: Some(true),
..Default::default()
@@ -147,7 +259,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
)
.expect("managed-only mode should treat missing managed allowlist as empty");
assert!(spec.config.network.allowed_domains.is_empty());
assert_eq!(spec.config.network.allowed_domains(), None);
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert!(spec.hard_deny_allowlist_misses);
@@ -156,7 +268,9 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
#[test]
fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
managed_allowed_domains_only: Some(true),
..Default::default()
@@ -169,18 +283,89 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m
)
.expect("managed-only mode should treat missing managed allowlist as empty");
assert!(spec.config.network.allowed_domains.is_empty());
assert_eq!(spec.config.network.allowed_domains(), None);
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert!(spec.hard_deny_allowlist_misses);
}
#[test]
fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"managed-blocked.example.com",
NetworkDomainPermissionToml::Deny,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
)
.expect("deny-only requirements should not constrain the allowlist");
assert_eq!(
spec.config.network.allowed_domains(),
Some(vec!["api.example.com".to_string()])
);
assert_eq!(spec.constraints.allowed_domains, None);
assert_eq!(spec.constraints.allowlist_expansion_enabled, None);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec!["managed-blocked.example.com".to_string()])
);
}
#[test]
fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"managed.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
)
.expect("allow-only requirements should not constrain the denylist");
assert_eq!(
spec.config.network.allowed_domains(),
Some(vec!["managed.example.com".to_string()])
);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.denied_domains, None);
assert_eq!(spec.constraints.denylist_expansion_enabled, None);
}
#[test]
fn requirements_denied_domains_are_a_baseline_for_default_mode() {
let mut config = NetworkProxyConfig::default();
config.network.denied_domains = vec!["blocked.example.com".to_string()];
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
denied_domains: Some(vec!["managed-blocked.example.com".to_string()]),
domains: Some(domain_permissions([(
"managed-blocked.example.com",
NetworkDomainPermissionToml::Deny,
)])),
..Default::default()
};
@@ -192,11 +377,55 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() {
.expect("default mode should merge managed and user deny entries");
assert_eq!(
spec.config.network.denied_domains,
vec![
spec.config.network.denied_domains(),
Some(vec![
"managed-blocked.example.com".to_string(),
"blocked.example.com".to_string()
]
])
);
assert_eq!(
spec.constraints.denied_domains,
Some(vec!["managed-blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true));
}
#[test]
fn requirements_denylist_expansion_keeps_user_entries_mutable() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"managed-blocked.example.com",
NetworkDomainPermissionToml::Deny,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed baseline should still allow user edits");
let mut candidate = spec.config.clone();
candidate.network.upsert_domain_permission(
"blocked.example.com".to_string(),
NetworkDomainPermission::Allow,
normalize_host,
);
assert_eq!(
candidate.network.allowed_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(
candidate.network.denied_domains(),
Some(vec!["managed-blocked.example.com".to_string()])
);
validate_policy_against_constraints(&candidate, &spec.constraints)
.expect("user denylist entries should not become managed constraints");
}
+127 -10
View File
@@ -5,8 +5,11 @@ use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -56,6 +59,98 @@ pub enum FilesystemPermissionToml {
Scoped(BTreeMap<String, FileSystemAccessMode>),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct NetworkDomainPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, NetworkDomainPermissionToml>,
}
impl NetworkDomainPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[cfg(test)]
pub(crate) fn allowed_domains(&self) -> Option<Vec<String>> {
let allowed_domains: Vec<String> = self
.entries
.iter()
.filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow))
.map(|(pattern, _)| pattern.clone())
.collect();
(!allowed_domains.is_empty()).then_some(allowed_domains)
}
#[cfg(test)]
pub(crate) fn denied_domains(&self) -> Option<Vec<String>> {
let denied_domains: Vec<String> = self
.entries
.iter()
.filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny))
.map(|(pattern, _)| pattern.clone())
.collect();
(!denied_domains.is_empty()).then_some(denied_domains)
}
}
#[derive(
Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum NetworkDomainPermissionToml {
Allow,
Deny,
}
impl std::fmt::Display for NetworkDomainPermissionToml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let permission = match self {
Self::Allow => "allow",
Self::Deny => "deny",
};
f.write_str(permission)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct NetworkUnixSocketPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, NetworkUnixSocketPermissionToml>,
}
impl NetworkUnixSocketPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) fn allow_unix_sockets(&self) -> Vec<String> {
self.entries
.iter()
.filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow))
.map(|(path, _)| path.clone())
.collect()
}
}
#[derive(
Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum NetworkUnixSocketPermissionToml {
Allow,
None,
}
impl std::fmt::Display for NetworkUnixSocketPermissionToml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let permission = match self {
Self::Allow => "allow",
Self::None => "none",
};
f.write_str(permission)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkToml {
@@ -69,9 +164,8 @@ pub struct NetworkToml {
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[schemars(with = "Option<NetworkModeSchema>")]
pub mode: Option<NetworkMode>,
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
}
@@ -114,14 +208,22 @@ impl NetworkToml {
if let Some(mode) = self.mode {
config.network.mode = mode;
}
if let Some(allowed_domains) = self.allowed_domains.as_ref() {
config.network.allowed_domains = allowed_domains.clone();
if let Some(domains) = self.domains.as_ref() {
overlay_network_domain_permissions(config, domains);
}
if let Some(denied_domains) = self.denied_domains.as_ref() {
config.network.denied_domains = denied_domains.clone();
}
if let Some(allow_unix_sockets) = self.allow_unix_sockets.as_ref() {
config.network.allow_unix_sockets = allow_unix_sockets.clone();
if let Some(unix_sockets) = self.unix_sockets.as_ref() {
let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default();
for (path, permission) in &unix_sockets.entries {
let permission = match permission {
NetworkUnixSocketPermissionToml::Allow => {
ProxyNetworkUnixSocketPermission::Allow
}
NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None,
};
proxy_unix_sockets.entries.insert(path.clone(), permission);
}
config.network.unix_sockets =
(!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets);
}
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
@@ -135,6 +237,21 @@ impl NetworkToml {
}
}
pub(crate) fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,
) {
for (pattern, permission) in &domains.entries {
let permission = match permission {
NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow,
NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny,
};
config
.network
.upsert_domain_permission(pattern.clone(), permission, normalize_host);
}
}
pub(crate) fn network_proxy_config_from_profile_network(
network: Option<&NetworkToml>,
) -> NetworkProxyConfig {
+133 -2
View File
@@ -9,8 +9,10 @@ use tempfile::TempDir;
#[test]
fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
let parsed =
normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true);
let parsed = normalize_absolute_path_for_platform(
r"\\?\D:\c\x\worktrees\2508\swift-base",
/*is_windows*/ true,
);
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
}
@@ -76,3 +78,132 @@ fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()>
Ok(())
}
#[test]
fn network_toml_ignores_legacy_network_list_keys() {
let parsed = toml::from_str::<NetworkToml>(
r#"
allowed_domains = ["openai.com"]
"#,
)
.expect("legacy network list keys should be ignored");
assert_eq!(parsed, NetworkToml::default());
}
#[test]
fn network_permission_containers_project_allowed_and_denied_entries() {
let domains = NetworkDomainPermissionsToml {
entries: BTreeMap::from([
(
"*.openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"api.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"blocked.example.com".to_string(),
NetworkDomainPermissionToml::Deny,
),
]),
};
let unix_sockets = NetworkUnixSocketPermissionsToml {
entries: BTreeMap::from([
(
"/tmp/example.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
),
(
"/tmp/ignored.sock".to_string(),
NetworkUnixSocketPermissionToml::None,
),
]),
};
assert_eq!(
domains.allowed_domains(),
Some(vec![
"*.openai.com".to_string(),
"api.example.com".to_string()
])
);
assert_eq!(
domains.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(
NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"api.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}
.denied_domains(),
None
);
assert_eq!(
unix_sockets.allow_unix_sockets(),
vec!["/tmp/example.sock".to_string()]
);
}
#[test]
fn network_toml_overlays_unix_socket_permissions_by_path() {
let mut config = NetworkProxyConfig::default();
NetworkToml {
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
entries: BTreeMap::from([
(
"/tmp/base.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
),
(
"/tmp/override.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
),
]),
}),
..Default::default()
}
.apply_to_network_proxy_config(&mut config);
NetworkToml {
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
entries: BTreeMap::from([
(
"/tmp/extra.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
),
(
"/tmp/override.sock".to_string(),
NetworkUnixSocketPermissionToml::None,
),
]),
}),
..Default::default()
}
.apply_to_network_proxy_config(&mut config);
assert_eq!(
config.network.unix_sockets,
Some(codex_network_proxy::NetworkUnixSocketPermissions {
entries: BTreeMap::from([
(
"/tmp/base.sock".to_string(),
ProxyNetworkUnixSocketPermission::Allow,
),
(
"/tmp/extra.sock".to_string(),
ProxyNetworkUnixSocketPermission::Allow,
),
(
"/tmp/override.sock".to_string(),
ProxyNetworkUnixSocketPermission::None,
),
]),
})
);
}
+4
View File
@@ -42,7 +42,11 @@ pub use codex_config::LoaderOverrides;
pub use codex_config::McpServerIdentity;
pub use codex_config::McpServerRequirement;
pub use codex_config::NetworkConstraints;
pub use codex_config::NetworkDomainPermissionToml;
pub use codex_config::NetworkDomainPermissionsToml;
pub use codex_config::NetworkRequirementsToml;
pub use codex_config::NetworkUnixSocketPermissionToml;
pub use codex_config::NetworkUnixSocketPermissionsToml;
pub use codex_config::RequirementSource;
pub use codex_config::ResidencyRequirement;
pub use codex_config::SandboxModeRequirement;
+56 -14
View File
@@ -797,7 +797,7 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh
CloudRequirementsLoader::new(async {
Err(CloudRequirementsLoadError::new(
codex_config::CloudRequirementsLoadErrorCode::RequestFailed,
None,
/*status_code*/ None,
"cloud requirements failed",
))
}),
@@ -833,7 +833,13 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let layers = load_config_layers_state(
&codex_home,
@@ -899,7 +905,13 @@ model_instructions_file = "child.txt"
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
@@ -965,7 +977,13 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let layers = load_config_layers_state(
&codex_home,
@@ -1018,7 +1036,7 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
true,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
@@ -1046,7 +1064,13 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
tokio::fs::write(nested_dot_codex.join(CONFIG_TOML_FILE), "foo = \"child\"\n").await?;
tokio::fs::create_dir_all(&project_dot_codex).await?;
make_config_for_test(&project_dot_codex, &project_root, TrustLevel::Trusted, None).await?;
make_config_for_test(
&project_dot_codex,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let user_config_path = project_dot_codex.join(CONFIG_TOML_FILE);
let user_config_contents = tokio::fs::read_to_string(&user_config_path).await?;
tokio::fs::write(
@@ -1068,7 +1092,7 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
true,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
@@ -1115,7 +1139,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
&codex_home_untrusted,
&project_root,
TrustLevel::Untrusted,
None,
/*project_root_markers*/ None,
)
.await?;
let untrusted_config_path = codex_home_untrusted.join(CONFIG_TOML_FILE);
@@ -1137,7 +1161,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
let project_layers_untrusted: Vec<_> = layers_untrusted
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
true,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
@@ -1175,7 +1199,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
let project_layers_unknown: Vec<_> = layers_unknown
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
true,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
@@ -1218,7 +1242,13 @@ enabled = false
"#,
)
.await?;
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
@@ -1303,7 +1333,13 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
let config_path = codex_home.join(CONFIG_TOML_FILE);
if let Some(trust_level) = trust_level {
make_config_for_test(&codex_home, &project_root, trust_level, None).await?;
make_config_for_test(
&codex_home,
&project_root,
trust_level,
/*project_root_markers*/ None,
)
.await?;
let config_contents = tokio::fs::read_to_string(&config_path).await?;
tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?;
} else {
@@ -1321,7 +1357,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
true,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
@@ -1358,7 +1394,13 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let cli_overrides = vec![(
+1 -3
View File
@@ -18,6 +18,7 @@ use codex_connectors::AllConnectorsCacheKey;
use codex_connectors::DirectoryListResponse;
use codex_login::token_data::TokenData;
use codex_protocol::protocol::SandboxPolicy;
use codex_tools::DiscoverableTool;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::de::DeserializeOwned;
@@ -44,8 +45,6 @@ use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::plugins::AppConnectorId;
use crate::plugins::PluginsManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::DiscoverableTool;
use codex_features::Feature;
pub use codex_connectors::CONNECTORS_CACHE_TTL;
@@ -133,7 +132,6 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)?
.into_iter()
.map(DiscoverablePluginInfo::from)
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
+53 -21
View File
@@ -171,7 +171,7 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
codex_app_tool(
"calendar_list_events",
"calendar",
None,
/*connector_name*/ None,
&["sample", "sample"],
),
),
@@ -229,8 +229,8 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal
.build()
.await
.expect("config should load");
let _ = config.features.set_enabled(Feature::Apps, true);
let cache_key = accessible_connectors_cache_key(&config, None);
let _ = config.features.set_enabled(Feature::Apps, /*enabled*/ true);
let cache_key = accessible_connectors_cache_key(&config, /*auth*/ None);
let tools = HashMap::from([
(
"mcp__codex_apps__calendar_list_events".to_string(),
@@ -253,7 +253,7 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal
]);
let cached = with_accessible_connectors_cache_cleared(|| {
refresh_accessible_connectors_cache_from_mcp_tools(&config, None, &tools);
refresh_accessible_connectors_cache_from_mcp_tools(&config, /*auth*/ None, &tools);
read_cached_accessible_connectors(&cache_key).expect("cache should be populated")
});
@@ -367,8 +367,8 @@ fn app_tool_policy_uses_global_defaults_for_destructive_hints() {
Some(&apps_config),
Some("calendar"),
"events/create",
None,
Some(&annotations(Some(true), None)),
/*tool_title*/ None,
Some(&annotations(Some(true), /*open_world_hint*/ None)),
);
assert_eq!(
@@ -392,7 +392,7 @@ fn app_is_enabled_uses_default_for_unconfigured_apps() {
};
assert!(!app_is_enabled(&apps_config, Some("calendar")));
assert!(!app_is_enabled(&apps_config, None));
assert!(!app_is_enabled(&apps_config, /*connector_id*/ None));
}
#[test]
@@ -518,7 +518,13 @@ enabled = true
.await
.expect("config should build");
let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None);
let policy = app_tool_policy(
&config,
Some("connector_123123"),
"events.list",
/*tool_title*/ None,
/*annotations*/ None,
);
assert_eq!(
policy,
AppToolPolicy {
@@ -555,7 +561,13 @@ async fn cloud_requirements_disable_connector_applies_without_user_apps_table()
.await
.expect("config should build");
let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None);
let policy = app_tool_policy(
&config,
Some("connector_123123"),
"events.list",
/*tool_title*/ None,
/*annotations*/ None,
);
assert_eq!(
policy,
AppToolPolicy {
@@ -602,7 +614,13 @@ enabled = true
.expect("apps config"),
);
let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None);
let policy = app_tool_policy(
&config,
Some("connector_123123"),
"events.list",
/*tool_title*/ None,
/*annotations*/ None,
);
assert_eq!(
policy,
AppToolPolicy {
@@ -637,7 +655,13 @@ async fn local_requirements_disable_connector_applies_without_user_apps_table()
ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements)
.expect("requirements stack");
let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None);
let policy = app_tool_policy(
&config,
Some("connector_123123"),
"events.list",
/*tool_title*/ None,
/*annotations*/ None,
);
assert_eq!(
policy,
AppToolPolicy {
@@ -699,8 +723,10 @@ fn app_tool_policy_honors_default_app_enabled_false() {
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
/*tool_title*/ None,
Some(&annotations(
/*destructive_hint*/ None, /*open_world_hint*/ None,
)),
);
assert_eq!(
@@ -737,8 +763,10 @@ fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() {
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
/*tool_title*/ None,
Some(&annotations(
/*destructive_hint*/ None, /*open_world_hint*/ None,
)),
);
assert_eq!(
@@ -779,7 +807,7 @@ fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() {
Some(&apps_config),
Some("calendar"),
"events/create",
None,
/*tool_title*/ None,
Some(&annotations(Some(true), Some(true))),
);
@@ -813,7 +841,7 @@ fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() {
Some(&apps_config),
Some("calendar"),
"events/create",
None,
/*tool_title*/ None,
Some(&annotations(Some(true), Some(true))),
);
@@ -847,8 +875,10 @@ fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints()
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
/*tool_title*/ None,
Some(&annotations(
/*destructive_hint*/ None, /*open_world_hint*/ None,
)),
);
assert_eq!(
@@ -883,8 +913,10 @@ fn app_tool_policy_uses_default_tools_approval_mode() {
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
/*tool_title*/ None,
Some(&annotations(
/*destructive_hint*/ None, /*open_world_hint*/ None,
)),
);
assert_eq!(
@@ -51,7 +51,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
AgentPath::root().join("worker").unwrap(),
Vec::new(),
text.to_string(),
true,
/*trigger_turn*/ true,
);
ResponseItem::Message {
id: None,
@@ -246,7 +246,8 @@ fn filters_non_api_messages() {
#[test]
fn non_last_reasoning_tokens_return_zero_when_no_user_messages() {
let history = create_history_with_items(vec![reasoning_with_encrypted_content(800)]);
let history =
create_history_with_items(vec![reasoning_with_encrypted_content(/*len*/ 800)]);
assert_eq!(history.get_non_last_reasoning_items_tokens(), 0);
}
@@ -254,11 +255,11 @@ fn non_last_reasoning_tokens_return_zero_when_no_user_messages() {
#[test]
fn non_last_reasoning_tokens_ignore_entries_after_last_user() {
let history = create_history_with_items(vec![
reasoning_with_encrypted_content(900),
reasoning_with_encrypted_content(/*len*/ 900),
user_msg("first"),
reasoning_with_encrypted_content(1_000),
reasoning_with_encrypted_content(/*len*/ 1_000),
user_msg("second"),
reasoning_with_encrypted_content(2_000),
reasoning_with_encrypted_content(/*len*/ 2_000),
]);
// first: (900 * 0.75 - 650) / 4 = 6.25 tokens
// second: (1000 * 0.75 - 650) / 4 = 25 tokens
@@ -330,7 +331,7 @@ fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_t
inter_agent_reply,
]);
history.drop_last_n_user_turns(1);
history.drop_last_n_user_turns(/*num_turns*/ 1);
assert_eq!(history.raw_items(), &vec![first_turn, first_reply]);
}
@@ -352,7 +353,7 @@ fn total_token_usage_includes_all_items_after_last_model_generated_item() {
total_tokens: 100,
..Default::default()
},
None,
/*model_context_window*/ None,
);
let added_user = user_msg("new user message");
let added_tool_output = custom_tool_call_output("tool-tail", "new tool output");
@@ -362,7 +363,7 @@ fn total_token_usage_includes_all_items_after_last_model_generated_item() {
);
assert_eq!(
history.get_total_token_usage(true),
history.get_total_token_usage(/*server_reasoning_included*/ true),
100 + estimate_item_token_count(&added_user)
+ estimate_item_token_count(&added_tool_output)
);
@@ -606,7 +607,12 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() {
#[test]
fn get_history_for_prompt_drops_ghost_commits() {
let items = vec![ResponseItem::GhostSnapshot {
ghost_commit: GhostCommit::new("ghost-1".to_string(), None, Vec::new(), Vec::new()),
ghost_commit: GhostCommit::new(
"ghost-1".to_string(),
/*parent*/ None,
Vec::new(),
Vec::new(),
),
}];
let history = create_history_with_items(items);
let modalities = default_input_modalities();
@@ -792,7 +798,7 @@ fn drop_last_n_user_turns_preserves_prefix() {
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
history.drop_last_n_user_turns(1);
history.drop_last_n_user_turns(/*num_turns*/ 1);
assert_eq!(
history.for_prompt(&modalities),
vec![
@@ -809,7 +815,7 @@ fn drop_last_n_user_turns_preserves_prefix() {
user_msg("u2"),
assistant_msg("a2"),
]);
history.drop_last_n_user_turns(99);
history.drop_last_n_user_turns(/*num_turns*/ 99);
assert_eq!(
history.for_prompt(&modalities),
vec![assistant_msg("session prefix item")]
@@ -838,7 +844,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
history.drop_last_n_user_turns(1);
history.drop_last_n_user_turns(/*num_turns*/ 1);
let expected_prefix_and_first_turn = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
@@ -892,7 +898,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
]);
history.drop_last_n_user_turns(2);
history.drop_last_n_user_turns(/*num_turns*/ 2);
assert_eq!(history.for_prompt(&modalities), expected_prefix_only);
let mut history = create_history_with_items(vec![
@@ -912,7 +918,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
]);
history.drop_last_n_user_turns(3);
history.drop_last_n_user_turns(/*num_turns*/ 3);
assert_eq!(history.for_prompt(&modalities), expected_prefix_only);
}
@@ -935,7 +941,7 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
let mut history = create_history_with_items(items);
let reference_context_item = reference_context_item();
history.set_reference_context_item(Some(reference_context_item.clone()));
history.drop_last_n_user_turns(1);
history.drop_last_n_user_turns(/*num_turns*/ 1);
assert_eq!(
history.clone().for_prompt(&modalities),
@@ -973,7 +979,7 @@ fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_b
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
history.set_reference_context_item(Some(reference_context_item()));
history.drop_last_n_user_turns(1);
history.drop_last_n_user_turns(/*num_turns*/ 1);
assert_eq!(
history.clone().for_prompt(&modalities),
@@ -1165,7 +1171,7 @@ fn format_exec_output_truncates_large_error() {
let truncated = truncate_exec_output(&large_error);
assert_truncated_message_matches(&truncated, line, 36250);
assert_truncated_message_matches(&truncated, line, /*expected_removed*/ 36250);
assert_ne!(truncated, large_error);
}
@@ -1174,7 +1180,7 @@ fn format_exec_output_marks_byte_truncation_without_omitted_lines() {
let long_line = "a".repeat(EXEC_FORMAT_MAX_BYTES + 10000);
let truncated = truncate_exec_output(&long_line);
assert_ne!(truncated, long_line);
assert_truncated_message_matches(&truncated, "a", 2500);
assert_truncated_message_matches(&truncated, "a", /*expected_removed*/ 2500);
assert!(
!truncated.contains("omitted"),
"line omission marker should not appear when no lines were dropped: {truncated}"
@@ -1196,7 +1202,7 @@ fn format_exec_output_reports_omitted_lines_and_keeps_head_and_tail() {
.collect();
let truncated = truncate_exec_output(&content);
assert_truncated_message_matches(&truncated, "line-0-", 34_723);
assert_truncated_message_matches(&truncated, "line-0-", /*expected_removed*/ 34_723);
assert!(
truncated.contains("line-0-"),
"expected head line to remain: {truncated}"
@@ -1219,7 +1225,7 @@ fn format_exec_output_prefers_line_marker_when_both_limits_exceeded() {
let truncated = truncate_exec_output(&content);
assert_truncated_message_matches(&truncated, "line-0-", 17_423);
assert_truncated_message_matches(&truncated, "line-0-", /*expected_removed*/ 17_423);
}
#[cfg(not(debug_assertions))]
@@ -86,8 +86,8 @@ fn detects_hook_prompt_fragment_and_roundtrips_escaping() {
let ContentItem::InputText { text } = content_item else {
panic!("expected input text content item");
};
let parsed =
parse_visible_hook_prompt_message(None, content.as_slice()).expect("visible hook prompt");
let parsed = parse_visible_hook_prompt_message(/*id*/ None, content.as_slice())
.expect("visible hook prompt");
assert_eq!(
parsed.fragments,
vec![HookPromptFragment {
-149
View File
@@ -1,149 +0,0 @@
use codex_protocol::custom_prompts::CustomPrompt;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use tokio::fs;
/// Return the default prompts directory: `$CODEX_HOME/prompts`.
/// If `CODEX_HOME` cannot be resolved, returns `None`.
pub fn default_prompts_dir() -> Option<PathBuf> {
crate::config::find_codex_home()
.ok()
.map(|home| home.join("prompts"))
}
/// Discover prompt files in the given directory, returning entries sorted by name.
/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty.
pub async fn discover_prompts_in(dir: &Path) -> Vec<CustomPrompt> {
discover_prompts_in_excluding(dir, &HashSet::new()).await
}
/// Discover prompt files in the given directory, excluding any with names in `exclude`.
/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty.
pub async fn discover_prompts_in_excluding(
dir: &Path,
exclude: &HashSet<String>,
) -> Vec<CustomPrompt> {
let mut out: Vec<CustomPrompt> = Vec::new();
let mut entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(_) => return out,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let is_file_like = fs::metadata(&path)
.await
.map(|m| m.is_file())
.unwrap_or(false);
if !is_file_like {
continue;
}
// Only include Markdown files with a .md extension.
let is_md = path
.extension()
.and_then(|s| s.to_str())
.map(|ext| ext.eq_ignore_ascii_case("md"))
.unwrap_or(false);
if !is_md {
continue;
}
let Some(name) = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
else {
continue;
};
if exclude.contains(&name) {
continue;
}
let content = match fs::read_to_string(&path).await {
Ok(s) => s,
Err(_) => continue,
};
let (description, argument_hint, body) = parse_frontmatter(&content);
out.push(CustomPrompt {
name,
path,
content: body,
description,
argument_hint,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Parse optional YAML-like frontmatter at the beginning of `content`.
/// Supported keys:
/// - `description`: short description shown in the slash popup
/// - `argument-hint` or `argument_hint`: brief hint string shown after the description
/// Returns (description, argument_hint, body_without_frontmatter).
fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, String) {
let mut segments = content.split_inclusive('\n');
let Some(first_segment) = segments.next() else {
return (None, None, String::new());
};
let first_line = first_segment.trim_end_matches(['\r', '\n']);
if first_line.trim() != "---" {
return (None, None, content.to_string());
}
let mut desc: Option<String> = None;
let mut hint: Option<String> = None;
let mut frontmatter_closed = false;
let mut consumed = first_segment.len();
for segment in segments {
let line = segment.trim_end_matches(['\r', '\n']);
let trimmed = line.trim();
if trimmed == "---" {
frontmatter_closed = true;
consumed += segment.len();
break;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
consumed += segment.len();
continue;
}
if let Some((k, v)) = trimmed.split_once(':') {
let key = k.trim().to_ascii_lowercase();
let mut val = v.trim().to_string();
if val.len() >= 2 {
let bytes = val.as_bytes();
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') {
val = val[1..val.len().saturating_sub(1)].to_string();
}
}
match key.as_str() {
"description" => desc = Some(val),
"argument-hint" | "argument_hint" => hint = Some(val),
_ => {}
}
}
consumed += segment.len();
}
if !frontmatter_closed {
// Unterminated frontmatter: treat input as-is.
return (None, None, content.to_string());
}
let body = if consumed >= content.len() {
String::new()
} else {
content[consumed..].to_string()
};
(desc, hint, body)
}
#[cfg(test)]
#[path = "custom_prompts_tests.rs"]
mod tests;
-95
View File
@@ -1,95 +0,0 @@
use super::*;
use std::fs;
use tempfile::tempdir;
#[tokio::test]
async fn empty_when_dir_missing() {
let tmp = tempdir().expect("create TempDir");
let missing = tmp.path().join("nope");
let found = discover_prompts_in(&missing).await;
assert!(found.is_empty());
}
#[tokio::test]
async fn discovers_and_sorts_files() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
fs::write(dir.join("b.md"), b"b").unwrap();
fs::write(dir.join("a.md"), b"a").unwrap();
fs::create_dir(dir.join("subdir")).unwrap();
let found = discover_prompts_in(dir).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["a", "b"]);
}
#[tokio::test]
async fn excludes_builtins() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
fs::write(dir.join("init.md"), b"ignored").unwrap();
fs::write(dir.join("foo.md"), b"ok").unwrap();
let mut exclude = HashSet::new();
exclude.insert("init".to_string());
let found = discover_prompts_in_excluding(dir, &exclude).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["foo"]);
}
#[tokio::test]
async fn skips_non_utf8_files() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
// Valid UTF-8 file
fs::write(dir.join("good.md"), b"hello").unwrap();
// Invalid UTF-8 content in .md file (e.g., lone 0xFF byte)
fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap();
let found = discover_prompts_in(dir).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["good"]);
}
#[tokio::test]
#[cfg(unix)]
async fn discovers_symlinked_md_files() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
// Create a real file
fs::write(dir.join("real.md"), b"real content").unwrap();
// Create a symlink to the real file
std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap();
let found = discover_prompts_in(dir).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
// Both real and link should be discovered, sorted alphabetically
assert_eq!(names, vec!["link", "real"]);
}
#[tokio::test]
async fn parses_frontmatter_and_strips_from_body() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
let file = dir.join("withmeta.md");
let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS";
fs::write(&file, text).unwrap();
let found = discover_prompts_in(dir).await;
assert_eq!(found.len(), 1);
let p = &found[0];
assert_eq!(p.name, "withmeta");
assert_eq!(p.description.as_deref(), Some("Quick review command"));
assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]"));
// Body should not include the frontmatter delimiters.
assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS");
}
#[test]
fn parse_frontmatter_preserves_body_newlines() {
let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n";
let (desc, hint, body) = parse_frontmatter(content);
assert_eq!(desc.as_deref(), Some("Line endings"));
assert_eq!(hint.as_deref(), Some("[arg]"));
assert_eq!(body, "First line\r\nSecond line\r\n");
}
+10 -2
View File
@@ -130,8 +130,16 @@ impl EnvironmentContext {
.as_ref()?;
Some(NetworkContext {
allowed_domains: network.allowed_domains.clone().unwrap_or_default(),
denied_domains: network.denied_domains.clone().unwrap_or_default(),
allowed_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
denied_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains)
.unwrap_or_default(),
})
}
+48 -48
View File
@@ -20,8 +20,8 @@ fn serialize_workspace_write_environment_context() {
fake_shell(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
None,
/*network*/ None,
/*subagents*/ None,
);
let expected = format!(
@@ -49,7 +49,7 @@ fn serialize_environment_context_with_network() {
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
Some(network),
None,
/*subagents*/ None,
);
let expected = format!(
@@ -73,12 +73,12 @@ fn serialize_environment_context_with_network() {
#[test]
fn serialize_read_only_environment_context() {
let context = EnvironmentContext::new(
None,
/*cwd*/ None,
fake_shell(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
None,
/*network*/ None,
/*subagents*/ None,
);
let expected = r#"<environment_context>
@@ -93,12 +93,12 @@ fn serialize_read_only_environment_context() {
#[test]
fn serialize_external_sandbox_environment_context() {
let context = EnvironmentContext::new(
None,
/*cwd*/ None,
fake_shell(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
None,
/*network*/ None,
/*subagents*/ None,
);
let expected = r#"<environment_context>
@@ -113,12 +113,12 @@ fn serialize_external_sandbox_environment_context() {
#[test]
fn serialize_external_sandbox_with_restricted_network_environment_context() {
let context = EnvironmentContext::new(
None,
/*cwd*/ None,
fake_shell(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
None,
/*network*/ None,
/*subagents*/ None,
);
let expected = r#"<environment_context>
@@ -133,12 +133,12 @@ fn serialize_external_sandbox_with_restricted_network_environment_context() {
#[test]
fn serialize_full_access_environment_context() {
let context = EnvironmentContext::new(
None,
/*cwd*/ None,
fake_shell(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
None,
/*network*/ None,
/*subagents*/ None,
);
let expected = r#"<environment_context>
@@ -155,18 +155,18 @@ fn equals_except_shell_compares_cwd() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
fake_shell(),
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
fake_shell(),
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert!(context1.equals_except_shell(&context2));
}
@@ -176,18 +176,18 @@ fn equals_except_shell_ignores_sandbox_policy() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
fake_shell(),
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
fake_shell(),
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert!(context1.equals_except_shell(&context2));
@@ -198,18 +198,18 @@ fn equals_except_shell_compares_cwd_differences() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo1")),
fake_shell(),
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo2")),
fake_shell(),
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert!(!context1.equals_except_shell(&context2));
@@ -224,10 +224,10 @@ fn equals_except_shell_ignores_shell() {
shell_path: "/bin/bash".into(),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
@@ -236,10 +236,10 @@ fn equals_except_shell_ignores_shell() {
shell_path: "/bin/zsh".into(),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
None,
None,
None,
None,
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert!(context1.equals_except_shell(&context2));
@@ -252,7 +252,7 @@ fn serialize_environment_context_with_subagents() {
fake_shell(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
/*network*/ None,
Some("- agent-1: atlas\n- agent-2".to_string()),
);
+6 -16
View File
@@ -439,7 +439,12 @@ impl std::fmt::Display for UsageLimitReachedError {
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
retry_suffix_after_or(self.resets_at.as_ref())
),
Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => {
Some(PlanType::Known(
KnownPlan::Team
| KnownPlan::SelfServeBusinessUsageBased
| KnownPlan::Business
| KnownPlan::EnterpriseCbpUsageBased,
)) => {
format!(
"You've hit your usage limit. To get more access now, send a request to your admin{}",
retry_suffix_after_or(self.resets_at.as_ref())
@@ -460,21 +465,6 @@ impl std::fmt::Display for UsageLimitReachedError {
"You've hit your usage limit.{}",
retry_suffix(self.resets_at.as_ref())
),
Some(PlanType::Unknown(plan))
if plan.eq_ignore_ascii_case("self_serve_business_usage_based") =>
{
match self
.rate_limits
.as_ref()
.and_then(|snapshot| snapshot.credits.as_ref())
.map(|credits| credits.has_credits)
{
Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue."
.to_string(),
Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue."
.to_string(),
}
}
Some(PlanType::Unknown(_)) | None => format!(
"You've hit your usage limit.{}",
retry_suffix(self.resets_at.as_ref())
+28
View File
@@ -243,6 +243,34 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
);
}
#[test]
fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
);
}
#[test]
fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
);
}
#[test]
fn usage_limit_reached_error_formats_default_for_other_plans() {
let err = UsageLimitReachedError {
+1 -1
View File
@@ -56,7 +56,7 @@ fn parses_user_message_with_text_and_two_images() {
#[test]
fn skips_local_image_label_text() {
let image_url = "data:image/png;base64,abc".to_string();
let label = codex_protocol::models::local_image_open_tag_text(1);
let label = codex_protocol::models::local_image_open_tag_text(/*label_number*/ 1);
let user_text = "Please review this image.".to_string();
let item = ResponseItem::Message {
+1 -1
View File
@@ -121,7 +121,7 @@ fn populate_env_inserts_thread_id() {
fn populate_env_omits_thread_id_when_missing() {
let vars = make_vars(&[("PATH", "/usr/bin")]);
let policy = ShellEnvironmentPolicy::default();
let result = populate_env(vars, &policy, None);
let result = populate_env(vars, &policy, /*thread_id*/ None);
let expected: HashMap<String, String> = hashmap! {
"PATH".to_string() => "/usr/bin".to_string(),
+13 -7
View File
@@ -115,7 +115,10 @@ async fn child_uses_parent_exec_policy_when_non_exec_policy_layers_differ() {
let mut child_config = parent_config.clone();
let mut layers: Vec<_> = child_config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect();
@@ -156,7 +159,10 @@ async fn child_does_not_use_parent_exec_policy_when_requirements_exec_policy_dif
child_config.config_layer_stack = ConfigLayerStack::new(
child_config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect(),
@@ -291,7 +297,7 @@ async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> {
"blocked.example.com",
codex_execpolicy::NetworkRuleProtocol::Https,
Decision::Forbidden,
None,
/*justification*/ None,
)?;
let requirements = ConfigRequirements {
@@ -338,7 +344,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
"blocked.example.com",
codex_execpolicy::NetworkRuleProtocol::Https,
Decision::Forbidden,
None,
/*justification*/ None,
)?;
let requirements = ConfigRequirements {
@@ -805,7 +811,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
&read_only_file_system_sandbox_policy(),
&command,
SandboxPermissions::RequireEscalated,
false,
/*used_complex_parsing*/ false,
)
);
}
@@ -823,7 +829,7 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
&restricted_file_system_policy,
&command,
SandboxPermissions::RequireEscalated,
false,
/*used_complex_parsing*/ false,
)
);
}
@@ -1348,7 +1354,7 @@ fn derive_requested_execpolicy_amendment_for_test(
fn derive_requested_execpolicy_amendment_returns_none_for_missing_prefix_rule() {
assert_eq!(
None,
derive_requested_execpolicy_amendment_for_test(None, &[])
derive_requested_execpolicy_amendment_for_test(/*prefix_rule*/ None, &[])
);
}
+30 -20
View File
@@ -24,7 +24,7 @@ fn make_exec_output(
#[test]
fn sandbox_detection_requires_keywords() {
let output = make_exec_output(1, "", "", "");
let output = make_exec_output(/*exit_code*/ 1, "", "", "");
assert!(!is_likely_sandbox_denied(
SandboxType::LinuxSeccomp,
&output
@@ -33,13 +33,13 @@ fn sandbox_detection_requires_keywords() {
#[test]
fn sandbox_detection_identifies_keyword_in_stderr() {
let output = make_exec_output(1, "", "Operation not permitted", "");
let output = make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", "");
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
}
#[test]
fn sandbox_detection_respects_quick_reject_exit_codes() {
let output = make_exec_output(127, "", "command not found", "");
let output = make_exec_output(/*exit_code*/ 127, "", "command not found", "");
assert!(!is_likely_sandbox_denied(
SandboxType::LinuxSeccomp,
&output
@@ -48,14 +48,14 @@ fn sandbox_detection_respects_quick_reject_exit_codes() {
#[test]
fn sandbox_detection_ignores_non_sandbox_mode() {
let output = make_exec_output(1, "", "Operation not permitted", "");
let output = make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", "");
assert!(!is_likely_sandbox_denied(SandboxType::None, &output));
}
#[test]
fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() {
let output = make_exec_output(
0,
/*exit_code*/ 0,
"",
"",
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#,
@@ -66,7 +66,7 @@ fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() {
#[test]
fn sandbox_detection_uses_aggregated_output() {
let output = make_exec_output(
101,
/*exit_code*/ 101,
"",
"",
"cargo failed: Read-only file system when writing target",
@@ -80,7 +80,7 @@ fn sandbox_detection_uses_aggregated_output() {
#[test]
fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() {
let output = make_exec_output(
0,
/*exit_code*/ 0,
"",
"",
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#,
@@ -100,9 +100,14 @@ async fn read_output_limits_retained_bytes_for_shell_capture() {
writer.write_all(&bytes).await.expect("write");
});
let out = read_output(reader, None, false, Some(EXEC_OUTPUT_MAX_BYTES))
.await
.expect("read");
let out = read_output(
reader,
/*stream*/ None,
/*is_stderr*/ false,
Some(EXEC_OUTPUT_MAX_BYTES),
)
.await
.expect("read");
assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES);
}
@@ -196,7 +201,11 @@ async fn read_output_retains_all_bytes_for_full_buffer_capture() {
writer.write_all(&bytes).await.expect("write");
});
let out = read_output(reader, None, false, None).await.expect("read");
let out = read_output(
reader, /*stream*/ None, /*is_stderr*/ false, /*max_bytes*/ None,
)
.await
.expect("read");
assert_eq!(out.text.len(), expected_len);
}
@@ -211,7 +220,7 @@ fn aggregate_output_keeps_all_bytes_when_uncapped() {
truncated_after_lines: None,
};
let aggregated = aggregate_output(&stdout, &stderr, None);
let aggregated = aggregate_output(&stdout, &stderr, /*max_bytes*/ None);
assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES * 2);
assert_eq!(
@@ -362,8 +371,8 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result
NetworkSandboxPolicy::Enabled,
cwd.as_path(),
&None,
false,
None,
/*use_legacy_landlock*/ false,
/*stdout_stream*/ None,
)
.await?;
@@ -675,14 +684,15 @@ fn windows_elevated_rejects_split_write_read_carveouts() {
#[test]
fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() {
let expected = codex_sandboxing::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let expected = codex_sandboxing::get_platform_sandbox(/*windows_sandbox_enabled*/ false)
.unwrap_or(SandboxType::None);
assert_eq!(
select_process_exec_tool_sandbox_type(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
codex_protocol::config_types::WindowsSandboxLevel::Disabled,
false,
/*enforce_managed_network*/ false,
),
expected
);
@@ -736,8 +746,8 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
None,
NetworkSandboxPolicy::Restricted,
None,
None,
/*stdout_stream*/ None,
/*after_spawn*/ None,
)
.await?;
assert!(output.timed_out);
@@ -798,8 +808,8 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
NetworkSandboxPolicy::Enabled,
cwd.as_path(),
&None,
false,
None,
/*use_legacy_landlock*/ false,
/*stdout_stream*/ None,
)
.await;
let output = match result {
@@ -390,7 +390,7 @@ fn import_skills_returns_only_new_skill_directory_count() {
fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target");
let copied_count = service_for_paths(claude_home, codex_home)
.import_skills(None)
.import_skills(/*cwd*/ None)
.expect("import skills");
assert_eq!(copied_count, 1);
+17 -15
View File
@@ -115,10 +115,10 @@ fn is_mutating_event_filters_non_mutating_event_kinds() {
fn register_dedupes_by_path_and_scope() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, _rx) = watcher.add_subscriber();
let _first = subscriber.register_path(path("/tmp/skills"), false);
let _second = subscriber.register_path(path("/tmp/skills"), false);
let _third = subscriber.register_path(path("/tmp/skills"), true);
let _fourth = subscriber.register_path(path("/tmp/other-skills"), true);
let _first = subscriber.register_path(path("/tmp/skills"), /*recursive*/ false);
let _second = subscriber.register_path(path("/tmp/skills"), /*recursive*/ false);
let _third = subscriber.register_path(path("/tmp/skills"), /*recursive*/ true);
let _fourth = subscriber.register_path(path("/tmp/other-skills"), /*recursive*/ true);
assert_eq!(
watcher.watch_counts_for_test(&path("/tmp/skills")),
@@ -134,7 +134,7 @@ fn register_dedupes_by_path_and_scope() {
fn watch_registration_drop_unregisters_paths() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, _rx) = watcher.add_subscriber();
let registration = subscriber.register_path(path("/tmp/skills"), true);
let registration = subscriber.register_path(path("/tmp/skills"), /*recursive*/ true);
drop(registration);
@@ -146,7 +146,7 @@ fn subscriber_drop_unregisters_paths() {
let watcher = Arc::new(FileWatcher::noop());
let registration = {
let (subscriber, _rx) = watcher.add_subscriber();
subscriber.register_path(path("/tmp/skills"), true)
subscriber.register_path(path("/tmp/skills"), /*recursive*/ true)
};
assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None);
@@ -174,8 +174,8 @@ fn recursive_registration_downgrades_to_non_recursive_after_drop() {
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
let (subscriber, _rx) = watcher.add_subscriber();
let non_recursive = subscriber.register_path(root.clone(), false);
let recursive = subscriber.register_path(root.clone(), true);
let non_recursive = subscriber.register_path(root.clone(), /*recursive*/ false);
let recursive = subscriber.register_path(root.clone(), /*recursive*/ true);
{
let inner = watcher.inner.as_ref().expect("watcher inner");
@@ -209,7 +209,7 @@ fn unregister_holds_state_lock_until_unwatch_finishes() {
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
let (unregister_subscriber, _unregister_rx) = watcher.add_subscriber();
let (register_subscriber, _register_rx) = watcher.add_subscriber();
let registration = unregister_subscriber.register_path(root.clone(), true);
let registration = unregister_subscriber.register_path(root.clone(), /*recursive*/ true);
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner_guard = inner.lock().expect("inner lock");
@@ -229,7 +229,8 @@ fn unregister_holds_state_lock_until_unwatch_finishes() {
let register_root = root.clone();
let register_thread = std::thread::spawn(move || {
let registration = register_subscriber.register_path(register_root, false);
let registration =
register_subscriber.register_path(register_root, /*recursive*/ false);
(register_subscriber, registration)
});
@@ -257,8 +258,8 @@ async fn matching_subscribers_are_notified() {
let watcher = Arc::new(FileWatcher::noop());
let (skills_subscriber, skills_rx) = watcher.add_subscriber();
let (plugins_subscriber, plugins_rx) = watcher.add_subscriber();
let _skills = skills_subscriber.register_path(path("/tmp/skills"), true);
let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), true);
let _skills = skills_subscriber.register_path(path("/tmp/skills"), /*recursive*/ true);
let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), /*recursive*/ true);
let mut skills_rx = ThrottledWatchReceiver::new(skills_rx, TEST_THROTTLE_INTERVAL);
let mut plugins_rx = ThrottledWatchReceiver::new(plugins_rx, TEST_THROTTLE_INTERVAL);
@@ -285,7 +286,7 @@ async fn matching_subscribers_are_notified() {
async fn non_recursive_watch_ignores_grandchildren() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills"), false);
let _registration = subscriber.register_path(path("/tmp/skills"), /*recursive*/ false);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
watcher
@@ -300,7 +301,8 @@ async fn non_recursive_watch_ignores_grandchildren() {
async fn ancestor_events_notify_child_watches() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), false);
let _registration =
subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), /*recursive*/ false);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
watcher.send_paths_for_test(vec![path("/tmp/skills")]).await;
@@ -321,7 +323,7 @@ async fn ancestor_events_notify_child_watches() {
async fn spawn_event_loop_filters_non_mutating_events() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills"), true);
let _registration = subscriber.register_path(path("/tmp/skills"), /*recursive*/ true);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
let (raw_tx, raw_rx) = mpsc::unbounded_channel();
watcher.spawn_event_loop_for_test(raw_rx);
+2 -2
View File
@@ -72,7 +72,7 @@ async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
#[tokio::test]
async fn test_recent_commits_non_git_directory_returns_empty() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let entries = recent_commits(temp_dir.path(), 10).await;
let entries = recent_commits(temp_dir.path(), /*limit*/ 10).await;
assert!(entries.is_empty(), "expected no commits outside a git repo");
}
@@ -133,7 +133,7 @@ async fn test_recent_commits_orders_and_limits() {
.expect("git commit 3");
// Request the latest 3 commits; should be our three changes in reverse time order.
let entries = recent_commits(&repo_path, 3).await;
let entries = recent_commits(&repo_path, /*limit*/ 3).await;
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].subject, "third change");
assert_eq!(entries[1].subject, "second change");
+12 -8
View File
@@ -746,9 +746,13 @@ mod tests {
#[test]
fn guardian_review_session_config_change_invalidates_cached_session() {
let parent_config = crate::config::test_config();
let cached_spawn_config =
build_guardian_review_session_config(&parent_config, None, "active-model", None)
.expect("cached guardian config");
let cached_spawn_config = build_guardian_review_session_config(
&parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect("cached guardian config");
let cached_reuse_key =
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config);
@@ -757,9 +761,9 @@ mod tests {
Some("https://guardian.example.invalid/v1".to_string());
let next_spawn_config = build_guardian_review_session_config(
&changed_parent_config,
None,
/*live_network_config*/ None,
"active-model",
None,
/*reasoning_effort*/ None,
)
.expect("next guardian config");
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config);
@@ -775,7 +779,7 @@ mod tests {
async fn run_before_review_deadline_times_out_before_future_completes() {
let outcome = run_before_review_deadline(
tokio::time::Instant::now() + Duration::from_millis(10),
None,
/*external_cancel*/ None,
async {
tokio::time::sleep(Duration::from_millis(50)).await;
},
@@ -816,7 +820,7 @@ mod tests {
let outcome = run_before_review_deadline_with_cancel(
tokio::time::Instant::now() + Duration::from_millis(10),
None,
/*external_cancel*/ None,
&cancel_token,
async {
tokio::time::sleep(Duration::from_millis(50)).await;
@@ -862,7 +866,7 @@ mod tests {
let outcome = run_before_review_deadline_with_cancel(
tokio::time::Instant::now() + Duration::from_secs(1),
None,
/*external_cancel*/ None,
&cancel_token,
async { 42usize },
)
+60 -29
View File
@@ -11,6 +11,8 @@ use crate::config::test_config;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::FeatureRequirementsToml;
use crate::config_loader::NetworkConstraints;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::protocol::SandboxPolicy;
@@ -249,7 +251,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() {
let content = "prefix ".repeat(200) + &" suffix".repeat(200);
let truncated = guardian_truncate_text(&content, 20);
let truncated = guardian_truncate_text(&content, /*token_cap*/ 20);
assert!(truncated.starts_with("prefix"));
assert!(truncated.contains("<truncated omitted_approx_tokens=\""));
@@ -390,7 +392,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
.to_string(),
},
None,
/*retry_reason*/ None,
cancel_token,
)
.await;
@@ -554,7 +556,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
Arc::clone(&turn),
prompt,
guardian_output_schema(),
None,
/*external_cancel*/ None,
)
.await;
let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else {
@@ -632,7 +634,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
Arc::clone(&turn),
first_prompt,
guardian_output_schema(),
None,
/*external_cancel*/ None,
)
.await;
let second_prompt = build_guardian_prompt_items(
@@ -657,7 +659,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
Arc::clone(&turn),
second_prompt,
guardian_output_schema(),
None,
/*external_cancel*/ None,
)
.await;
@@ -770,7 +772,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
},
None,
/*retry_reason*/ None,
)
.await;
@@ -882,7 +884,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
justification: Some("Inspect repo state before proceeding.".to_string()),
};
assert_eq!(
review_approval_request(&session, &turn, initial_request, None).await,
review_approval_request(&session, &turn, initial_request, /*retry_reason*/ None).await,
ReviewDecision::Approved
);
@@ -971,7 +973,12 @@ fn guardian_review_session_config_preserves_parent_network_proxy() {
NetworkProxyConfig::default(),
Some(NetworkConstraints {
enabled: Some(true),
allowed_domains: Some(vec!["github.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([(
"github.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}),
parent_config.permissions.sandbox_policy.get(),
@@ -981,7 +988,7 @@ fn guardian_review_session_config_preserves_parent_network_proxy() {
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
None,
/*live_network_config*/ None,
"parent-active-model",
Some(codex_protocol::openai_models::ReasoningEffort::Low),
)
@@ -1012,9 +1019,13 @@ fn guardian_review_session_config_overrides_parent_developer_instructions() {
parent_config.developer_instructions =
Some("parent or managed config should not replace guardian policy".to_string());
let guardian_config =
build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None)
.expect("guardian config");
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect("guardian config");
assert_eq!(
guardian_config.developer_instructions,
@@ -1027,11 +1038,13 @@ fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut parent_config = test_config();
let mut parent_network = NetworkProxyConfig::default();
parent_network.network.enabled = true;
parent_network.network.allowed_domains = vec!["parent.example".to_string()];
parent_network
.network
.set_allowed_domains(vec!["parent.example".to_string()]);
parent_config.permissions.network = Some(
NetworkProxySpec::from_config_and_constraints(
parent_network,
None,
/*requirements*/ None,
parent_config.permissions.sandbox_policy.get(),
)
.expect("parent network proxy spec"),
@@ -1039,13 +1052,15 @@ fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut live_network = NetworkProxyConfig::default();
live_network.network.enabled = true;
live_network.network.allowed_domains = vec!["github.com".to_string()];
live_network
.network
.set_allowed_domains(vec!["github.com".to_string()]);
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
Some(live_network.clone()),
"active-model",
None,
/*reasoning_effort*/ None,
)
.expect("guardian config");
@@ -1054,7 +1069,7 @@ fn guardian_review_session_config_uses_live_network_proxy_state() {
Some(
NetworkProxySpec::from_config_and_constraints(
live_network,
None,
/*requirements*/ None,
&SandboxPolicy::new_read_only_policy(),
)
.expect("live network proxy spec")
@@ -1076,9 +1091,13 @@ fn guardian_review_session_config_rejects_pinned_collab_feature() {
)
.expect("managed features");
let err =
build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None)
.expect_err("guardian config should fail when collab is pinned on");
let err = build_guardian_review_session_config_for_test(
&parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect_err("guardian config should fail when collab is pinned on");
assert!(
err.to_string()
@@ -1091,9 +1110,13 @@ fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_
let mut parent_config = test_config();
parent_config.model = Some("configured-model".to_string());
let guardian_config =
build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None)
.expect("guardian config");
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.model, Some("active-model".to_string()));
}
@@ -1124,9 +1147,13 @@ fn guardian_review_session_config_uses_requirements_guardian_override() {
)
.expect("load config");
let guardian_config =
build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None)
.expect("guardian config");
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect("guardian config");
assert_eq!(
guardian_config.developer_instructions,
@@ -1152,9 +1179,13 @@ fn guardian_review_session_config_uses_default_guardian_policy_without_requireme
)
.expect("load config");
let guardian_config =
build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None)
.expect("guardian config");
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect("guardian config");
assert_eq!(
guardian_config.developer_instructions,
+3 -3
View File
@@ -30,7 +30,6 @@ pub mod config_loader;
pub mod connectors;
mod context_manager;
mod contextual_user_message;
pub mod custom_prompts;
pub use codex_utils_path::env;
mod environment_context;
pub mod error;
@@ -66,6 +65,7 @@ pub mod utils;
pub use utils::path_utils;
pub mod personality_migration;
pub mod plugins;
mod provider_auth;
pub(crate) mod mentions {
pub(crate) use crate::plugins::build_connector_slug_counts;
pub(crate) use crate::plugins::build_skill_name_counts;
@@ -105,6 +105,7 @@ mod text_encoding;
mod unified_exec;
pub mod windows_sandbox;
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
pub use codex_protocol::config_types::ModelProviderAuthInfo;
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
@@ -134,8 +135,7 @@ pub type CodexConversation = CodexThread;
pub use auth::AuthManager;
pub use auth::CodexAuth;
pub use codex_analytics::AnalyticsEventsClient;
pub use codex_analytics::AnalyticsFact;
pub use codex_analytics::CustomAnalyticsFact;
pub use codex_analytics::AppServerRpcTransport;
mod default_client_forwarding;
/// Default Codex HTTP client headers and reqwest construction.
+15 -4
View File
@@ -209,7 +209,7 @@ mod tests {
#[test]
fn resolve_oauth_scopes_prefers_configured_over_discovered() {
let resolved = resolve_oauth_scopes(
None,
/*explicit_scopes*/ None,
Some(vec!["configured".to_string()]),
Some(vec!["discovered".to_string()]),
);
@@ -225,7 +225,11 @@ mod tests {
#[test]
fn resolve_oauth_scopes_uses_discovered_when_needed() {
let resolved = resolve_oauth_scopes(None, None, Some(vec!["discovered".to_string()]));
let resolved = resolve_oauth_scopes(
/*explicit_scopes*/ None,
/*configured_scopes*/ None,
Some(vec!["discovered".to_string()]),
);
assert_eq!(
resolved,
@@ -238,7 +242,11 @@ mod tests {
#[test]
fn resolve_oauth_scopes_preserves_explicitly_empty_configured_scopes() {
let resolved = resolve_oauth_scopes(None, Some(Vec::new()), Some(vec!["ignored".into()]));
let resolved = resolve_oauth_scopes(
/*explicit_scopes*/ None,
Some(Vec::new()),
Some(vec!["ignored".into()]),
);
assert_eq!(
resolved,
@@ -251,7 +259,10 @@ mod tests {
#[test]
fn resolve_oauth_scopes_falls_back_to_empty() {
let resolved = resolve_oauth_scopes(None, None, None);
let resolved = resolve_oauth_scopes(
/*explicit_scopes*/ None, /*configured_scopes*/ None,
/*discovered_scopes*/ None,
);
assert_eq!(
resolved,
+26
View File
@@ -33,6 +33,32 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
/// name we expose to the model by replacing any disallowed character with `_`.
pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String {
let mut sanitized = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '_' {
sanitized.push(c);
} else {
sanitized.push('_');
}
}
if sanitized.is_empty() {
"_".to_string()
} else {
sanitized
}
}
pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
sanitize_responses_api_tool_name(&format!(
"{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
+18 -3
View File
@@ -52,6 +52,14 @@ fn split_qualified_tool_name_returns_server_and_tool() {
);
}
#[test]
fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
assert_eq!(
qualified_mcp_tool_name_prefix("Some-Server"),
"mcp__Some_Server__".to_string()
);
}
#[test]
fn split_qualified_tool_name_rejects_invalid_names() {
assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None);
@@ -159,7 +167,12 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() {
let mut config = crate::config::test_config();
config.chatgpt_base_url = "https://chatgpt.com".to_string();
let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config);
let mut servers = with_codex_apps_mcp(
HashMap::new(),
/*connectors_enabled*/ false,
/*auth*/ None,
&config,
);
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
config
@@ -167,7 +180,9 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() {
.enable(Feature::Apps)
.expect("test config should allow apps");
servers = with_codex_apps_mcp(servers, true, None, &config);
servers = with_codex_apps_mcp(
servers, /*connectors_enabled*/ true, /*auth*/ None, &config,
);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
@@ -244,7 +259,7 @@ async fn effective_mcp_servers_include_plugins_without_overriding_user_config()
.expect("test config should accept MCP servers");
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let effective = mcp_manager.effective_servers(&config, None);
let effective = mcp_manager.effective_servers(&config, /*auth*/ None);
let sample = effective.get("sample").expect("user server should exist");
let docs = effective.get("docs").expect("plugin server should exist");
+2 -21
View File
@@ -22,6 +22,7 @@ use std::time::Instant;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::mcp::auth::McpAuthStatusEntry;
use crate::mcp::sanitize_responses_api_tool_name;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
@@ -93,7 +94,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
/// Default timeout for initializing MCP server & initially listing tools.
pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
/// Default timeout for individual tool calls.
const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
@@ -104,26 +105,6 @@ const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms";
const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms";
const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms";
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
/// name we expose to the model by replacing any disallowed character with `_`.
fn sanitize_responses_api_tool_name(name: &str) -> String {
let mut sanitized = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '_' {
sanitized.push(c);
} else {
sanitized.push('_');
}
}
if sanitized.is_empty() {
"_".to_string()
} else {
sanitized
}
}
fn sha1_hex(s: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(s.as_bytes());
@@ -562,7 +562,7 @@ fn mcp_init_error_display_prompts_for_login_when_auth_required() {
let server_name = "example";
let err: StartupOutcomeError = anyhow::anyhow!("Auth required for server").into();
let display = mcp_init_error_display(server_name, None, &err);
let display = mcp_init_error_display(server_name, /*entry*/ None, &err);
let expected = format!(
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
@@ -609,10 +609,10 @@ fn mcp_init_error_display_includes_startup_timeout_hint() {
let server_name = "slow";
let err: StartupOutcomeError = anyhow::anyhow!("request timed out").into();
let display = mcp_init_error_display(server_name, None, &err);
let display = mcp_init_error_display(server_name, /*entry*/ None, &err);
assert_eq!(
"MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
"MCP client for `slow` timed out after 30 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
display
);
}
@@ -330,7 +330,7 @@ mod tests {
&templates,
"codex_apps",
Some("github"),
None,
/*connector_name*/ None,
Some("add_comment"),
Some(&json!({})),
);
@@ -361,7 +361,7 @@ mod tests {
&templates,
"codex_apps",
Some("calendar"),
None,
/*connector_name*/ None,
Some("create_event"),
Some(&json!({})),
),
+4 -8
View File
@@ -700,14 +700,14 @@ async fn maybe_request_mcp_tool_approval(
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let approval_required = requires_mcp_tool_approval(annotations);
if !approval_required && approval_mode != AppToolApproval::Prompt {
return None;
}
let mut monitor_reason = None;
let auto_approved_by_policy = 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,
@@ -729,10 +729,6 @@ async fn maybe_request_mcp_tool_approval(
}
}
if approval_mode == AppToolApproval::Auto && !approval_required {
return None;
}
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
let persistent_approval_key =
persistent_mcp_tool_approval_key(invocation, metadata, approval_mode);
+208 -42
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::codex::make_session_and_context;
use crate::codex::make_session_and_context_with_rx;
use crate::config::ApprovalsReviewer;
use crate::config::ConfigBuilder;
use crate::config::ConfigToml;
@@ -9,6 +10,7 @@ use crate::config::types::AppToolsConfig;
use crate::config::types::AppsConfigToml;
use crate::config::types::McpServerConfig;
use crate::config::types::McpServerToolConfig;
use crate::state::ActiveTurn;
use codex_config::CONFIG_TOML_FILE;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -70,13 +72,13 @@ fn prompt_options(
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), None);
let annotations = annotations(Some(false), Some(true), /*open_world*/ None);
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
}
#[test]
fn approval_required_when_read_only_false_and_open_world() {
let annotations = annotations(Some(false), None, Some(true));
let annotations = annotations(Some(false), /*destructive*/ None, Some(true));
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
}
@@ -88,12 +90,16 @@ fn approval_required_when_destructive_even_if_read_only_true() {
#[test]
fn approval_required_when_annotations_are_absent() {
assert_eq!(requires_mcp_tool_approval(None), true);
assert_eq!(requires_mcp_tool_approval(/*annotations*/ None), true);
}
#[test]
fn approval_not_required_when_read_only_and_other_hints_are_absent() {
let annotations = annotations(Some(true), None, None);
let annotations = annotations(
Some(true),
/*destructive*/ None,
/*open_world*/ None,
);
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false);
}
@@ -185,7 +191,9 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
CODEX_APPS_MCP_SERVER_NAME,
"create_event",
Some("Calendar"),
prompt_options(true, true),
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
Some("Allow Calendar to create an event?"),
);
@@ -219,7 +227,9 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
]),
question,
message_override: Some("Allow Calendar to create an event?"),
prompt_options: prompt_options(true, true),
prompt_options: prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
},
);
@@ -277,9 +287,11 @@ fn custom_mcp_tool_question_mentions_server_name() {
"q".to_string(),
"custom_server",
"run_action",
None,
prompt_options(false, false),
None,
/*connector_name*/ None,
prompt_options(
/*allow_session_remember*/ false, /*allow_persistent_approval*/ false,
),
/*question_override*/ None,
);
assert_eq!(question.header, "Approve app tool call?");
@@ -303,9 +315,11 @@ fn codex_apps_tool_question_uses_fallback_app_label() {
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
None,
prompt_options(true, true),
None,
/*connector_name*/ None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
/*question_override*/ None,
);
assert_eq!(
@@ -321,8 +335,10 @@ fn trusted_codex_apps_tool_question_offers_always_allow() {
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Calendar"),
prompt_options(true, true),
None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
/*question_override*/ None,
);
let options = question.options.expect("options");
@@ -361,8 +377,12 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Calendar"),
mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false),
None,
mcp_tool_approval_prompt_options(
Some(&session_key),
Some(&persistent_key),
/*tool_call_mcp_elicitation_enabled*/ false,
),
/*question_override*/ None,
);
assert_eq!(
@@ -386,9 +406,11 @@ fn custom_mcp_tool_question_offers_session_remember_and_always_allow() {
"q".to_string(),
"custom_server",
"run_action",
None,
prompt_options(true, true),
None,
/*connector_name*/ None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
/*question_override*/ None,
);
assert_eq!(
@@ -421,11 +443,15 @@ fn custom_servers_support_session_and_persistent_approval() {
};
assert_eq!(
session_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto),
session_mcp_tool_approval_key(&invocation, /*metadata*/ None, AppToolApproval::Auto),
Some(expected.clone())
);
assert_eq!(
persistent_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto),
persistent_mcp_tool_approval_key(
&invocation,
/*metadata*/ None,
AppToolApproval::Auto
),
Some(expected)
);
}
@@ -437,7 +463,13 @@ fn codex_apps_connectors_support_persistent_approval() {
tool: "calendar/list_events".to_string(),
arguments: None,
};
let metadata = approval_metadata(Some("calendar"), Some("Calendar"), None, None, None);
let metadata = approval_metadata(
Some("calendar"),
Some("Calendar"),
/*connector_description*/ None,
/*tool_title*/ None,
/*tool_description*/ None,
);
let expected = McpToolApprovalKey {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
connector_id: Some("calendar".to_string()),
@@ -473,7 +505,8 @@ fn sanitize_mcp_tool_result_for_model_rewrites_image_content() {
meta: None,
});
let got = sanitize_mcp_tool_result_for_model(false, result).expect("sanitized result");
let got = sanitize_mcp_tool_result_for_model(/*supports_image_input*/ false, result)
.expect("sanitized result");
assert_eq!(
got.content,
@@ -503,8 +536,11 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() {
meta: Some(serde_json::json!({"k": "v"})),
};
let got =
sanitize_mcp_tool_result_for_model(true, Ok(original.clone())).expect("unsanitized result");
let got = sanitize_mcp_tool_result_for_model(
/*supports_image_input*/ true,
Ok(original.clone()),
)
.expect("unsanitized result");
assert_eq!(got, original);
}
@@ -604,10 +640,12 @@ fn approval_elicitation_meta_marks_tool_approvals() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
"custom_server",
None,
None,
None,
prompt_options(false, false),
/*metadata*/ None,
/*tool_params*/ None,
/*tool_params_display*/ None,
prompt_options(
/*allow_session_remember*/ false, /*allow_persistent_approval*/ false
),
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
@@ -621,15 +659,17 @@ fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_server
build_mcp_tool_approval_elicitation_meta(
"custom_server",
Some(&approval_metadata(
None,
None,
None,
/*connector_id*/ None,
/*connector_name*/ None,
/*connector_description*/ None,
Some("Run Action"),
Some("Runs the selected action."),
)),
Some(&serde_json::json!({"id": 1})),
None,
prompt_options(true, true),
/*tool_params_display*/ None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true
),
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
@@ -740,11 +780,11 @@ fn prepare_arc_request_action_serializes_mcp_tool_call_shape() {
let action = prepare_arc_request_action(
&invocation,
Some(&approval_metadata(
None,
/*connector_id*/ None,
Some("Playwright"),
None,
/*connector_description*/ None,
Some("Navigate"),
None,
/*tool_description*/ None,
)),
);
@@ -794,8 +834,10 @@ fn approval_elicitation_meta_includes_connector_source_for_codex_apps() {
Some(&serde_json::json!({
"calendar_id": "primary",
})),
None,
prompt_options(false, false),
/*tool_params_display*/ None,
prompt_options(
/*allow_session_remember*/ false, /*allow_persistent_approval*/ false
),
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
@@ -827,8 +869,10 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
Some(&serde_json::json!({
"calendar_id": "primary",
})),
None,
prompt_options(true, true),
/*tool_params_display*/ None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true
),
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
@@ -1168,7 +1212,11 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
arguments: None,
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(true), None, None)),
annotations: Some(annotations(
Some(true),
/*destructive*/ None,
/*open_world*/ None,
)),
connector_id: None,
connector_name: None,
connector_description: None,
@@ -1190,6 +1238,124 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
assert_eq!(decision, None);
}
#[tokio::test]
async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let (mut session, mut turn_context) = make_session_and_context().await;
turn_context
.approval_policy
.set(AskForApproval::OnRequest)
.expect("test setup should allow updating approval policy");
let mut config = (*turn_context.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
let config = Arc::new(config);
let models_manager = Arc::new(crate::test_support::models_manager_with_provider(
config.codex_home.clone(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
));
session.services.models_manager = models_manager;
turn_context.config = Arc::clone(&config);
turn_context.provider = config.model_provider.clone();
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: "custom_server".to_string(),
tool: "read_only_tool".to_string(),
arguments: None,
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(
Some(true),
/*destructive*/ None,
/*open_world*/ None,
)),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
codex_apps_meta: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-guardian",
&invocation,
Some(&metadata),
AppToolApproval::Auto,
)
.await;
assert_eq!(decision, None);
}
#[tokio::test]
async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval() {
let (session, turn_context, _rx_event) = make_session_and_context_with_rx().await;
{
let mut active_turn = session.active_turn.lock().await;
*active_turn = Some(ActiveTurn::default());
}
let invocation = McpInvocation {
server: "custom_server".to_string(),
tool: "read_only_tool".to_string(),
arguments: None,
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(
Some(true),
/*destructive*/ None,
/*open_world*/ None,
)),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
codex_apps_meta: None,
};
let mut approval_task = {
let session = Arc::clone(&session);
let turn_context = Arc::clone(&turn_context);
tokio::spawn(async move {
maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-prompt",
&invocation,
Some(&metadata),
AppToolApproval::Prompt,
)
.await
})
};
assert!(
tokio::time::timeout(std::time::Duration::from_millis(200), &mut approval_task)
.await
.is_err(),
"prompt mode should wait for approval instead of auto-allowing"
);
approval_task.abort();
}
#[tokio::test]
async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
use wiremock::Mock;
+1 -1
View File
@@ -132,7 +132,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
let thread_id = match session
.services
.agent_control
.spawn_agent(agent_config, prompt, Some(source))
.spawn_agent(agent_config, prompt.into(), Some(source))
.await
{
Ok(thread_id) => thread_id,
+1 -1
View File
@@ -29,7 +29,7 @@ fn fixed_thread_id() -> ThreadId {
#[test]
fn rollout_summary_file_stem_uses_uuid_timestamp_and_hash_when_slug_missing() {
let thread_id = fixed_thread_id();
let memory = stage1_output_with_slug(thread_id, None);
let memory = stage1_output_with_slug(thread_id, /*rollout_slug*/ None);
assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX);
assert_eq!(
+32 -26
View File
@@ -526,8 +526,8 @@ mod phase2 {
thread_id,
self.session.conversation_id,
source_updated_at,
3_600,
64,
/*lease_seconds*/ 3_600,
/*max_running_jobs*/ 64,
)
.await
.expect("claim stage-1 job");
@@ -543,7 +543,7 @@ mod phase2 {
source_updated_at,
"raw memory",
"rollout summary",
None,
/*rollout_slug*/ None,
)
.await
.expect("mark stage-1 success"),
@@ -571,24 +571,24 @@ mod phase2 {
#[test]
fn completion_watermark_never_regresses_below_claimed_input_watermark() {
let stage1_output = stage1_output_with_source_updated_at(123);
let stage1_output = stage1_output_with_source_updated_at(/*source_updated_at*/ 123);
let completion = phase2::get_watermark(1_000, &[stage1_output]);
let completion = phase2::get_watermark(/*claimed_watermark*/ 1_000, &[stage1_output]);
pretty_assertions::assert_eq!(completion, 1_000);
}
#[test]
fn completion_watermark_uses_claimed_watermark_when_there_are_no_memories() {
let completion = phase2::get_watermark(777, &[]);
let completion = phase2::get_watermark(/*claimed_watermark*/ 777, &[]);
pretty_assertions::assert_eq!(completion, 777);
}
#[test]
fn completion_watermark_uses_latest_memory_timestamp_when_it_is_newer() {
let older = stage1_output_with_source_updated_at(123);
let newer = stage1_output_with_source_updated_at(456);
let older = stage1_output_with_source_updated_at(/*source_updated_at*/ 123);
let newer = stage1_output_with_source_updated_at(/*source_updated_at*/ 456);
let completion = phase2::get_watermark(200, &[older, newer]);
let completion = phase2::get_watermark(/*claimed_watermark*/ 200, &[older, newer]);
pretty_assertions::assert_eq!(completion, 456);
}
@@ -608,12 +608,12 @@ mod phase2 {
let harness = DispatchHarness::new().await;
harness
.state_db
.enqueue_global_consolidation(123)
.enqueue_global_consolidation(/*input_watermark*/ 123)
.await
.expect("enqueue global consolidation");
let claimed = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim running global lock");
assert!(
@@ -625,7 +625,7 @@ mod phase2 {
let running_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim while lock is still running");
pretty_assertions::assert_eq!(running_claim, Phase2JobClaimOutcome::SkippedRunning);
@@ -641,7 +641,7 @@ mod phase2 {
let stale_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 0)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 0)
.await
.expect("claim stale global lock");
assert!(
@@ -653,7 +653,7 @@ mod phase2 {
let post_dispatch_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim after stale lock dispatch");
assert!(
@@ -759,7 +759,7 @@ mod phase2 {
harness
.state_db
.enqueue_global_consolidation(999)
.enqueue_global_consolidation(/*input_watermark*/ 999)
.await
.expect("enqueue global consolidation");
@@ -801,7 +801,7 @@ mod phase2 {
);
let next_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim global job after empty consolidation success");
pretty_assertions::assert_eq!(next_claim, Phase2JobClaimOutcome::SkippedNotDirty);
@@ -817,7 +817,7 @@ mod phase2 {
let harness = DispatchHarness::new().await;
harness
.state_db
.enqueue_global_consolidation(99)
.enqueue_global_consolidation(/*input_watermark*/ 99)
.await
.expect("enqueue global consolidation");
let mut constrained_config = harness.config.as_ref().clone();
@@ -828,7 +828,7 @@ mod phase2 {
let retry_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim global job after sandbox policy failure");
pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedNotDirty);
@@ -840,7 +840,7 @@ mod phase2 {
#[tokio::test]
async fn dispatch_marks_job_for_retry_when_syncing_artifacts_fails() {
let harness = DispatchHarness::new().await;
harness.seed_stage1_output(100).await;
harness.seed_stage1_output(/*source_updated_at*/ 100).await;
let root = memory_root(&harness.config.codex_home);
tokio::fs::write(&root, "not a directory")
.await
@@ -850,7 +850,7 @@ mod phase2 {
let retry_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim global job after sync failure");
pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedNotDirty);
@@ -862,7 +862,7 @@ mod phase2 {
#[tokio::test]
async fn dispatch_marks_job_for_retry_when_rebuilding_raw_memories_fails() {
let harness = DispatchHarness::new().await;
harness.seed_stage1_output(100).await;
harness.seed_stage1_output(/*source_updated_at*/ 100).await;
let root = memory_root(&harness.config.codex_home);
tokio::fs::create_dir_all(raw_memories_file(&root))
.await
@@ -872,7 +872,7 @@ mod phase2 {
let retry_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim global job after rebuild failure");
pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedNotDirty);
@@ -917,7 +917,13 @@ mod phase2 {
.expect("upsert thread metadata");
let claim = state_db
.try_claim_stage1_job(thread_id, session.conversation_id, 100, 3_600, 64)
.try_claim_stage1_job(
thread_id,
session.conversation_id,
/*source_updated_at*/ 100,
/*lease_seconds*/ 3_600,
/*max_running_jobs*/ 64,
)
.await
.expect("claim stage-1 job");
let ownership_token = match claim {
@@ -929,10 +935,10 @@ mod phase2 {
.mark_stage1_job_succeeded(
thread_id,
&ownership_token,
100,
/*source_updated_at*/ 100,
"raw memory",
"rollout summary",
None,
/*rollout_slug*/ None,
)
.await
.expect("mark stage-1 success"),
@@ -942,7 +948,7 @@ mod phase2 {
phase2::run(&session, Arc::clone(&config)).await;
let retry_claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
.await
.expect("claim global job after spawn failure");
pretty_assertions::assert_eq!(
+4 -4
View File
@@ -37,8 +37,8 @@ async fn lookup_reads_history_entries() {
let (log_id, count) = history_metadata_for_file(&history_path).await;
assert_eq!(count, entries.len());
let second_entry =
lookup_history_entry(&history_path, log_id, 1).expect("fetch second history entry");
let second_entry = lookup_history_entry(&history_path, log_id, /*offset*/ 1)
.expect("fetch second history entry");
assert_eq!(second_entry, entries[1]);
}
@@ -80,8 +80,8 @@ async fn lookup_uses_stable_log_id_after_appends() {
)
.expect("append history entry");
let fetched =
lookup_history_entry(&history_path, log_id, 1).expect("lookup appended history entry");
let fetched = lookup_history_entry(&history_path, log_id, /*offset*/ 1)
.expect("lookup appended history entry");
assert_eq!(fetched, appended);
}
+40
View File
@@ -9,6 +9,7 @@ use crate::auth::AuthMode;
use crate::error::EnvVarError;
use codex_api::Provider as ApiProvider;
use codex_api::provider::RetryConfig as ApiRetryConfig;
use codex_protocol::config_types::ModelProviderAuthInfo;
use http::HeaderMap;
use http::header::HeaderName;
use http::header::HeaderValue;
@@ -86,6 +87,9 @@ pub struct ModelProviderInfo {
/// this may be necessary when using this programmatically.
pub experimental_bearer_token: Option<String>,
/// Command-backed bearer-token configuration for this provider.
pub auth: Option<ModelProviderAuthInfo>,
/// Which wire protocol this provider expects.
#[serde(default)]
pub wire_api: WireApi,
@@ -130,6 +134,36 @@ pub struct ModelProviderInfo {
}
impl ModelProviderInfo {
pub(crate) fn validate(&self) -> std::result::Result<(), String> {
let Some(auth) = self.auth.as_ref() else {
return Ok(());
};
if auth.command.trim().is_empty() {
return Err("provider auth.command must not be empty".to_string());
}
let mut conflicts = Vec::new();
if self.env_key.is_some() {
conflicts.push("env_key");
}
if self.experimental_bearer_token.is_some() {
conflicts.push("experimental_bearer_token");
}
if self.requires_openai_auth {
conflicts.push("requires_openai_auth");
}
if conflicts.is_empty() {
Ok(())
} else {
Err(format!(
"provider auth cannot be combined with {}",
conflicts.join(", ")
))
}
}
fn build_header_map(&self) -> crate::error::Result<HeaderMap> {
let capacity = self.http_headers.as_ref().map_or(0, HashMap::len)
+ self.env_http_headers.as_ref().map_or(0, HashMap::len);
@@ -246,6 +280,7 @@ impl ModelProviderInfo {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(
@@ -277,6 +312,10 @@ impl ModelProviderInfo {
pub fn is_openai(&self) -> bool {
self.name == OPENAI_PROVIDER_NAME
}
pub(crate) fn has_command_auth(&self) -> bool {
self.auth.is_some()
}
}
pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234;
@@ -338,6 +377,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api,
query_params: None,
http_headers: None,
@@ -1,5 +1,9 @@
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use pretty_assertions::assert_eq;
use std::num::NonZeroU64;
use tempfile::tempdir;
#[test]
fn test_deserialize_ollama_model_provider_toml() {
@@ -13,6 +17,7 @@ base_url = "http://localhost:11434/v1"
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -43,6 +48,7 @@ query_params = { api-version = "2025-04-01-preview" }
env_key: Some("AZURE_OPENAI_API_KEY".into()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: Some(maplit::hashmap! {
"api-version".to_string() => "2025-04-01-preview".to_string(),
@@ -76,6 +82,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
env_key: Some("API_KEY".into()),
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(maplit::hashmap! {
@@ -121,3 +128,31 @@ supports_websockets = true
let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap();
assert_eq!(provider.websocket_connect_timeout_ms, Some(15_000));
}
#[test]
fn test_deserialize_provider_auth_config_defaults() {
let base_dir = tempdir().unwrap();
let provider_toml = r#"
name = "Corp"
[auth]
command = "./scripts/print-token"
args = ["--format=text"]
"#;
let provider: ModelProviderInfo = {
let _guard = AbsolutePathBufGuard::new(base_dir.path());
toml::from_str(provider_toml).unwrap()
};
assert_eq!(
provider.auth,
Some(ModelProviderAuthInfo {
command: "./scripts/print-token".to_string(),
args: vec!["--format=text".to_string()],
timeout_ms: NonZeroU64::new(5_000).unwrap(),
refresh_interval_ms: NonZeroU64::new(300_000).unwrap(),
cwd: AbsolutePathBuf::resolve_path_against_base(".", base_dir.path()).unwrap(),
})
);
}
@@ -2,13 +2,19 @@ use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_template::Template;
use std::sync::LazyLock;
const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md");
const COLLABORATION_MODE_DEFAULT: &str =
include_str!("../../templates/collaboration_mode/default.md");
const KNOWN_MODE_NAMES_PLACEHOLDER: &str = "{{KNOWN_MODE_NAMES}}";
const REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER: &str = "{{REQUEST_USER_INPUT_AVAILABILITY}}";
const ASKING_QUESTIONS_GUIDANCE_PLACEHOLDER: &str = "{{ASKING_QUESTIONS_GUIDANCE}}";
const KNOWN_MODE_NAMES_TEMPLATE_KEY: &str = "KNOWN_MODE_NAMES";
const REQUEST_USER_INPUT_AVAILABILITY_TEMPLATE_KEY: &str = "REQUEST_USER_INPUT_AVAILABILITY";
const ASKING_QUESTIONS_GUIDANCE_TEMPLATE_KEY: &str = "ASKING_QUESTIONS_GUIDANCE";
static COLLABORATION_MODE_DEFAULT_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(COLLABORATION_MODE_DEFAULT)
.unwrap_or_else(|err| panic!("collaboration mode default template must parse: {err}"))
});
/// Stores feature flags that control collaboration-mode behavior.
///
@@ -21,7 +27,7 @@ pub struct CollaborationModesConfig {
pub default_mode_request_user_input: bool,
}
pub(crate) fn builtin_collaboration_mode_presets(
pub fn builtin_collaboration_mode_presets(
collaboration_modes_config: CollaborationModesConfig,
) -> Vec<CollaborationModeMask> {
vec![plan_preset(), default_preset(collaboration_modes_config)]
@@ -56,16 +62,19 @@ fn default_mode_instructions(collaboration_modes_config: CollaborationModesConfi
let asking_questions_guidance = asking_questions_guidance_message(
collaboration_modes_config.default_mode_request_user_input,
);
COLLABORATION_MODE_DEFAULT
.replace(KNOWN_MODE_NAMES_PLACEHOLDER, &known_mode_names)
.replace(
REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER,
&request_user_input_availability,
)
.replace(
ASKING_QUESTIONS_GUIDANCE_PLACEHOLDER,
&asking_questions_guidance,
)
COLLABORATION_MODE_DEFAULT_TEMPLATE
.render([
(KNOWN_MODE_NAMES_TEMPLATE_KEY, known_mode_names.as_str()),
(
REQUEST_USER_INPUT_AVAILABILITY_TEMPLATE_KEY,
request_user_input_availability.as_str(),
),
(
ASKING_QUESTIONS_GUIDANCE_TEMPLATE_KEY,
asking_questions_guidance.as_str(),
),
])
.unwrap_or_else(|err| panic!("collaboration mode default template must render: {err}"))
}
fn format_mode_names(modes: &[ModeKind]) -> String {
@@ -23,16 +23,18 @@ fn default_mode_instructions_replace_mode_names_placeholder() {
.expect("default preset should include instructions")
.expect("default instructions should be set");
assert!(!default_instructions.contains(KNOWN_MODE_NAMES_PLACEHOLDER));
assert!(!default_instructions.contains(REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER));
assert!(!default_instructions.contains(ASKING_QUESTIONS_GUIDANCE_PLACEHOLDER));
assert!(!default_instructions.contains("{{KNOWN_MODE_NAMES}}"));
assert!(!default_instructions.contains("{{REQUEST_USER_INPUT_AVAILABILITY}}"));
assert!(!default_instructions.contains("{{ASKING_QUESTIONS_GUIDANCE}}"));
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
let expected_snippet = format!("Known mode names are {known_mode_names}.");
assert!(default_instructions.contains(&expected_snippet));
let expected_availability_message =
request_user_input_availability_message(ModeKind::Default, true);
let expected_availability_message = request_user_input_availability_message(
ModeKind::Default,
/*default_mode_request_user_input*/ true,
);
assert!(default_instructions.contains(&expected_availability_message));
assert!(default_instructions.contains("prefer using the `request_user_input` tool"));
}
+5 -1
View File
@@ -14,6 +14,7 @@ use crate::model_provider_info::ModelProviderInfo;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
use crate::models_manager::model_info;
use crate::provider_auth::required_auth_manager_for_provider;
use crate::response_debug_context::extract_response_debug_context;
use crate::response_debug_context::telemetry_transport_error_message;
use crate::util::FeedbackRequestTags;
@@ -212,6 +213,7 @@ impl ModelsManager {
collaboration_modes_config: CollaborationModesConfig,
provider: ModelProviderInfo,
) -> Self {
let auth_manager = required_auth_manager_for_provider(auth_manager, &provider);
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
let catalog_mode = if model_catalog.is_some() {
@@ -396,7 +398,9 @@ impl ModelsManager {
return Ok(());
}
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) {
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt)
&& !self.provider.has_command_auth()
{
if matches!(
refresh_strategy,
RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached
+171 -18
View File
@@ -1,5 +1,7 @@
use super::*;
use crate::AuthManager;
use crate::CodexAuth;
use crate::ModelProviderAuthInfo;
use crate::auth::AuthCredentialsStoreMode;
use crate::config::ConfigBuilder;
use crate::model_provider_info::WireApi;
@@ -13,8 +15,10 @@ use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::num::NonZeroU64;
use std::sync::Arc;
use std::sync::Mutex;
use tempfile::TempDir;
use tempfile::tempdir;
use tracing::Event;
use tracing::Subscriber;
@@ -24,7 +28,12 @@ use tracing_subscriber::layer::Context;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo {
remote_model_with_visibility(slug, display, priority, "list")
@@ -79,6 +88,7 @@ fn provider_for(base_url: String) -> ModelProviderInfo {
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -92,6 +102,95 @@ fn provider_for(base_url: String) -> ModelProviderInfo {
}
}
struct ProviderAuthScript {
tempdir: TempDir,
command: String,
args: Vec<String>,
}
impl ProviderAuthScript {
fn new(tokens: &[&str]) -> std::io::Result<Self> {
let tempdir = tempfile::tempdir()?;
let tokens_file = tempdir.path().join("tokens.txt");
let mut token_file_contents = String::new();
for token in tokens {
token_file_contents.push_str(token);
token_file_contents.push('\n');
}
std::fs::write(&tokens_file, token_file_contents)?;
#[cfg(unix)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.sh");
std::fs::write(
&script_path,
r#"#!/bin/sh
first_line=$(sed -n '1p' tokens.txt)
printf '%s\n' "$first_line"
tail -n +2 tokens.txt > tokens.next
mv tokens.next tokens.txt
"#,
)?;
let mut permissions = std::fs::metadata(&script_path)?.permissions();
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&script_path, permissions)?;
("./print-token.sh".to_string(), Vec::new())
};
#[cfg(windows)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.ps1");
std::fs::write(
&script_path,
r#"$lines = Get-Content -Path tokens.txt
if ($lines.Count -eq 0) { exit 1 }
Write-Output $lines[0]
$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
"#,
)?;
(
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
".\\print-token.ps1".to_string(),
],
)
};
Ok(Self {
tempdir,
command,
args,
})
}
fn auth_config(&self) -> ModelProviderAuthInfo {
ModelProviderAuthInfo {
command: self.command.clone(),
args: self.args.clone(),
timeout_ms: non_zero_u64(/*value*/ 1_000),
refresh_interval_ms: non_zero_u64(/*value*/ 60_000),
cwd: match codex_utils_absolute_path::AbsolutePathBuf::try_from(self.tempdir.path()) {
Ok(cwd) => cwd,
Err(err) => panic!("tempdir should be absolute: {err}"),
},
}
}
}
fn non_zero_u64(value: u64) -> NonZeroU64 {
match NonZeroU64::new(value) {
Some(value) => value,
None => panic!("expected non-zero value: {value}"),
}
}
#[derive(Default)]
struct TagCollectorVisitor {
tags: BTreeMap<String, String>,
@@ -145,7 +244,7 @@ async fn get_model_info_tracks_fallback_usage() {
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
None,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
);
let known_slug = manager
@@ -175,7 +274,7 @@ async fn get_model_info_uses_custom_catalog() {
.build()
.await
.expect("load default test config");
let mut overlay = remote_model("gpt-overlay", "Overlay", 0);
let mut overlay = remote_model("gpt-overlay", "Overlay", /*priority*/ 0);
overlay.supports_image_detail_original = true;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
@@ -208,7 +307,7 @@ async fn get_model_info_matches_namespaced_suffix() {
.build()
.await
.expect("load default test config");
let mut remote = remote_model("gpt-image", "Image", 0);
let mut remote = remote_model("gpt-image", "Image", /*priority*/ 0);
remote.supports_image_detail_original = true;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let manager = ModelsManager::new(
@@ -240,7 +339,7 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() {
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
None,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
);
let known_slug = manager
@@ -262,8 +361,8 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() {
async fn refresh_available_models_sorts_by_priority() {
let server = MockServer::start().await;
let remote_models = vec![
remote_model("priority-low", "Low", 1),
remote_model("priority-high", "High", 0),
remote_model("priority-low", "Low", /*priority*/ 1),
remote_model("priority-high", "High", /*priority*/ 0),
];
let models_mock = mount_models_once(
&server,
@@ -310,10 +409,54 @@ async fn refresh_available_models_sorts_by_priority() {
);
}
#[tokio::test]
async fn refresh_available_models_uses_provider_auth_token() {
let server = MockServer::start().await;
let auth_script = ProviderAuthScript::new(&["provider-token"]).unwrap();
let remote_models = vec![remote_model(
"provider-model",
"Provider",
/*priority*/ 0,
)];
Mock::given(method("GET"))
.and(path("/models"))
.and(header_regex("Authorization", "Bearer provider-token"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_json(ModelsResponse {
models: remote_models.clone(),
}),
)
.expect(1)
.mount(&server)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("unused"));
let provider = ModelProviderInfo {
auth: Some(auth_script.auth_config()),
..provider_for(server.uri())
};
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::Online)
.await
.expect("refresh succeeds");
assert_models_contain(&manager.get_remote_models().await, &remote_models);
}
#[tokio::test]
async fn refresh_available_models_uses_cache_when_fresh() {
let server = MockServer::start().await;
let remote_models = vec![remote_model("cached", "Cached", 5)];
let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)];
let models_mock = mount_models_once(
&server,
ModelsResponse {
@@ -354,7 +497,7 @@ async fn refresh_available_models_uses_cache_when_fresh() {
#[tokio::test]
async fn refresh_available_models_refetches_when_cache_stale() {
let server = MockServer::start().await;
let initial_models = vec![remote_model("stale", "Stale", 1)];
let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)];
let initial_mock = mount_models_once(
&server,
ModelsResponse {
@@ -387,7 +530,7 @@ async fn refresh_available_models_refetches_when_cache_stale() {
.await
.expect("cache manipulation succeeds");
let updated_models = vec![remote_model("fresh", "Fresh", 9)];
let updated_models = vec![remote_model("fresh", "Fresh", /*priority*/ 9)];
server.reset().await;
let refreshed_mock = mount_models_once(
&server,
@@ -417,7 +560,7 @@ async fn refresh_available_models_refetches_when_cache_stale() {
#[tokio::test]
async fn refresh_available_models_refetches_when_version_mismatch() {
let server = MockServer::start().await;
let initial_models = vec![remote_model("old", "Old", 1)];
let initial_models = vec![remote_model("old", "Old", /*priority*/ 1)];
let initial_mock = mount_models_once(
&server,
ModelsResponse {
@@ -450,7 +593,7 @@ async fn refresh_available_models_refetches_when_version_mismatch() {
.await
.expect("cache mutation succeeds");
let updated_models = vec![remote_model("new", "New", 2)];
let updated_models = vec![remote_model("new", "New", /*priority*/ 2)];
server.reset().await;
let refreshed_mock = mount_models_once(
&server,
@@ -480,7 +623,11 @@ async fn refresh_available_models_refetches_when_version_mismatch() {
#[tokio::test]
async fn refresh_available_models_drops_removed_remote_models() {
let server = MockServer::start().await;
let initial_models = vec![remote_model("remote-old", "Remote Old", 1)];
let initial_models = vec![remote_model(
"remote-old",
"Remote Old",
/*priority*/ 1,
)];
let initial_mock = mount_models_once(
&server,
ModelsResponse {
@@ -506,7 +653,11 @@ async fn refresh_available_models_drops_removed_remote_models() {
.expect("initial refresh succeeds");
server.reset().await;
let refreshed_models = vec![remote_model("remote-new", "Remote New", 1)];
let refreshed_models = vec![remote_model(
"remote-new",
"Remote New",
/*priority*/ 1,
)];
let refreshed_mock = mount_models_once(
&server,
ModelsResponse {
@@ -550,7 +701,7 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() {
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model(dynamic_slug, "No Auth", 1)],
models: vec![remote_model(dynamic_slug, "No Auth", /*priority*/ 1)],
},
)
.await;
@@ -558,7 +709,7 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() {
let codex_home = tempdir().expect("temp dir");
let auth_manager = Arc::new(AuthManager::new(
codex_home.path().to_path_buf(),
false,
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
));
let provider = provider_for(server.uri());
@@ -621,7 +772,7 @@ fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() {
.unwrap(),
);
telemetry.on_request(
1,
/*attempt*/ 1,
Some(StatusCode::UNAUTHORIZED),
Some(&TransportError::Http {
status: StatusCode::UNAUTHORIZED,
@@ -695,8 +846,10 @@ fn build_available_models_picks_default_after_hiding_hidden_models() {
provider,
);
let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide");
let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list");
let hidden_model =
remote_model_with_visibility("hidden", "Hidden", /*priority*/ 0, "hide");
let visible_model =
remote_model_with_visibility("visible", "Visible", /*priority*/ 1, "list");
let expected_hidden = ModelPreset::from(hidden_model.clone());
let mut expected_visible = ModelPreset::from(visible_model.clone());
+26 -14
View File
@@ -1,6 +1,7 @@
use crate::config::NetworkToml;
use crate::config::PermissionsToml;
use crate::config::find_codex_home;
use crate::config::overlay_network_domain_permissions;
use crate::config::resolve_permission_profile;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
@@ -150,13 +151,20 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx
if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets {
constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets);
}
if let Some(allowed_domains) = network.allowed_domains {
constraints.allowed_domains = Some(allowed_domains);
if let Some(domains) = network.domains.as_ref() {
let mut config = NetworkProxyConfig::default();
if let Some(allowed_domains) = constraints.allowed_domains.take() {
config.network.set_allowed_domains(allowed_domains);
}
if let Some(denied_domains) = constraints.denied_domains.take() {
config.network.set_denied_domains(denied_domains);
}
overlay_network_domain_permissions(&mut config, domains);
constraints.allowed_domains = config.network.allowed_domains();
constraints.denied_domains = config.network.denied_domains();
}
if let Some(denied_domains) = network.denied_domains {
constraints.denied_domains = Some(denied_domains);
}
if let Some(allow_unix_sockets) = network.allow_unix_sockets {
if let Some(unix_sockets) = network.unix_sockets.as_ref() {
let allow_unix_sockets = unix_sockets.allow_unix_sockets();
constraints.allow_unix_sockets = Some(allow_unix_sockets);
}
if let Some(allow_local_binding) = network.allow_local_binding {
@@ -220,24 +228,28 @@ fn apply_exec_policy_network_rules(
let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains();
for host in allowed_domains {
upsert_network_domain(
&mut config.network.allowed_domains,
&mut config.network.denied_domains,
config,
host,
codex_network_proxy::NetworkDomainPermission::Allow,
);
}
for host in denied_domains {
upsert_network_domain(
&mut config.network.denied_domains,
&mut config.network.allowed_domains,
config,
host,
codex_network_proxy::NetworkDomainPermission::Deny,
);
}
}
fn upsert_network_domain(target: &mut Vec<String>, opposite: &mut Vec<String>, host: String) {
opposite.retain(|entry| normalize_host(entry) != host);
target.retain(|entry| normalize_host(entry) != host);
target.push(host);
fn upsert_network_domain(
config: &mut NetworkProxyConfig,
host: String,
permission: codex_network_proxy::NetworkDomainPermission,
) {
config
.network
.upsert_domain_permission(host, permission, normalize_host);
}
fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool {
+159 -13
View File
@@ -6,13 +6,16 @@ use codex_execpolicy::Policy;
use pretty_assertions::assert_eq;
#[test]
fn higher_precedence_profile_network_beats_lower_profile_network() {
fn higher_precedence_profile_network_overlays_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["lower.example.com"]
[permissions.workspace.network.domains]
"lower.example.com" = "allow"
"blocked.example.com" = "deny"
"#,
)
.expect("lower layer should parse");
@@ -21,7 +24,9 @@ allowed_domains = ["lower.example.com"]
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["higher.example.com"]
[permissions.workspace.network.domains]
"higher.example.com" = "allow"
"#,
)
.expect("higher layer should parse");
@@ -38,14 +43,76 @@ allowed_domains = ["higher.example.com"]
)
.expect("higher layer should apply");
assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]);
assert_eq!(
config.network.allowed_domains(),
Some(vec![
"lower.example.com".to_string(),
"higher.example.com".to_string()
])
);
assert_eq!(
config.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
}
#[test]
fn higher_precedence_profile_network_overrides_matching_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"shared.example.com" = "deny"
"other.example.com" = "allow"
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"shared.example.com" = "allow"
"#,
)
.expect("higher layer should parse");
let mut config = NetworkProxyConfig::default();
apply_network_tables(
&mut config,
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
apply_network_tables(
&mut config,
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should apply");
assert_eq!(
config.network.allowed_domains(),
Some(vec![
"other.example.com".to_string(),
"shared.example.com".to_string()
])
);
assert_eq!(config.network.denied_domains(), None);
}
#[test]
fn execpolicy_network_rules_overlay_network_lists() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["config.example.com".to_string()];
config.network.denied_domains = vec!["blocked.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["config.example.com".to_string()]);
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let mut exec_policy = Policy::empty();
exec_policy
@@ -53,7 +120,7 @@ fn execpolicy_network_rules_overlay_network_lists() {
"blocked.example.com",
NetworkRuleProtocol::Https,
Decision::Allow,
None,
/*justification*/ None,
)
.expect("allow rule should be valid");
exec_policy
@@ -61,22 +128,22 @@ fn execpolicy_network_rules_overlay_network_lists() {
"api.example.com",
NetworkRuleProtocol::Http,
Decision::Forbidden,
None,
/*justification*/ None,
)
.expect("deny rule should be valid");
apply_exec_policy_network_rules(&mut config, &exec_policy);
assert_eq!(
config.network.allowed_domains,
vec![
config.network.allowed_domains(),
Some(vec![
"config.example.com".to_string(),
"blocked.example.com".to_string()
]
])
);
assert_eq!(
config.network.denied_domains,
vec!["api.example.com".to_string()]
config.network.denied_domains(),
Some(vec!["api.example.com".to_string()])
);
}
@@ -102,3 +169,82 @@ dangerously_allow_all_unix_sockets = true
assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true));
}
#[test]
fn apply_network_constraints_skips_empty_domain_sides() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"managed.example.com" = "allow"
"#,
)
.expect("permissions profile should parse");
let network = selected_network_from_tables(
network_tables_from_toml(&config).expect("permissions profile should deserialize"),
)
.expect("permissions profile should select a network table")
.expect("network table should be present");
let mut constraints = NetworkProxyConstraints::default();
apply_network_constraints(network, &mut constraints);
assert_eq!(
constraints.allowed_domains,
Some(vec!["managed.example.com".to_string()])
);
assert_eq!(constraints.denied_domains, None);
}
#[test]
fn apply_network_constraints_overlay_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"blocked.example.com" = "deny"
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"api.example.com" = "allow"
"#,
)
.expect("higher layer should parse");
let lower_network = selected_network_from_tables(
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should select a network table")
.expect("lower network table should be present");
let higher_network = selected_network_from_tables(
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should select a network table")
.expect("higher network table should be present");
let mut constraints = NetworkProxyConstraints::default();
apply_network_constraints(lower_network, &mut constraints);
apply_network_constraints(higher_network, &mut constraints);
assert_eq!(
constraints.allowed_domains,
Some(vec!["api.example.com".to_string()])
);
assert_eq!(
constraints.denied_domains,
Some(vec!["blocked.example.com".to_string()])
);
}
@@ -20,7 +20,7 @@ fn image_detail_original_feature_enables_explicit_original_without_force() {
Some(ImageDetail::Original)
);
assert_eq!(
normalize_output_image_detail(&features, &model_info, None),
normalize_output_image_detail(&features, &model_info, /*detail*/ None),
None
);
}
+21 -6
View File
@@ -9,6 +9,7 @@ use super::PluginsManager;
use crate::config::Config;
use crate::config::types::ToolSuggestDiscoverableType;
use codex_features::Feature;
use codex_tools::DiscoverablePluginInfo;
const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"github@openai-curated",
@@ -23,7 +24,7 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
pub(crate) fn list_tool_suggest_discoverable_plugins(
config: &Config,
) -> anyhow::Result<Vec<PluginCapabilitySummary>> {
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
@@ -47,7 +48,7 @@ pub(crate) fn list_tool_suggest_discoverable_plugins(
return Ok(Vec::new());
};
let mut discoverable_plugins = Vec::<PluginCapabilitySummary>::new();
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::new();
for plugin in curated_marketplace.plugins {
if plugin.installed
|| (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
@@ -66,14 +67,28 @@ pub(crate) fn list_tool_suggest_discoverable_plugins(
marketplace_path: curated_marketplace.path.clone(),
},
) {
Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()),
Ok(plugin) => {
let plugin: PluginCapabilitySummary = plugin.plugin.into();
discoverable_plugins.push(DiscoverablePluginInfo {
id: plugin.config_name,
name: plugin.display_name,
description: plugin.description,
has_skills: plugin.has_skills,
mcp_server_names: plugin.mcp_server_names,
app_connector_ids: plugin
.app_connector_ids
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),
});
}
Err(err) => warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}"),
}
}
discoverable_plugins.sort_by(|left, right| {
left.display_name
.cmp(&right.display_name)
.then_with(|| left.config_name.cmp(&right.config_name))
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(discoverable_plugins)
}
@@ -5,7 +5,7 @@ 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 crate::plugins::test_support::write_plugins_feature_config;
use crate::tools::discoverable::DiscoverablePluginInfo;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -18,11 +18,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap();
assert_eq!(
discoverable_plugins,
@@ -52,11 +48,7 @@ plugins = false
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
@@ -76,11 +68,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap();
assert_eq!(
discoverable_plugins,
@@ -115,11 +103,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config).unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
@@ -140,11 +124,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap();
assert_eq!(
discoverable_plugins,
+66 -24
View File
@@ -29,6 +29,7 @@ use super::sync_openai_plugins_repo;
use crate::AuthManager;
use crate::SkillMetadata;
use crate::auth::CodexAuth;
use crate::config::CONFIG_TOML_FILE;
use crate::config::Config;
use crate::config::ConfigService;
use crate::config::ConfigServiceError;
@@ -1008,28 +1009,7 @@ impl PluginsManager {
auth_manager: Arc<AuthManager>,
) {
if config.features.enabled(Feature::Plugins) {
let mut configured_curated_plugin_ids =
configured_plugins_from_stack(&config.config_layer_stack)
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id)
if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME =>
{
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid configured plugin key during curated sync setup"
);
None
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
self.start_curated_repo_sync(configured_curated_plugin_ids);
self.start_curated_repo_sync();
start_startup_remote_plugin_sync_once(
Arc::clone(self),
self.codex_home.clone(),
@@ -1054,7 +1034,7 @@ impl PluginsManager {
}
}
fn start_curated_repo_sync(self: &Arc<Self>, configured_curated_plugin_ids: Vec<PluginId>) {
fn start_curated_repo_sync(self: &Arc<Self>) {
if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) {
return;
}
@@ -1065,6 +1045,8 @@ impl PluginsManager {
.spawn(
move || match sync_openai_plugins_repo(codex_home.as_path()) {
Ok(curated_plugin_version) => {
let configured_curated_plugin_ids =
configured_curated_plugin_ids_from_codex_home(codex_home.as_path());
match refresh_curated_plugin_cache(
codex_home.as_path(),
&curated_plugin_version,
@@ -1333,7 +1315,13 @@ fn configured_plugins_from_stack(
let Some(user_layer) = config_layer_stack.get_user_layer() else {
return HashMap::new();
};
let Some(plugins_value) = user_layer.config.get("plugins") else {
configured_plugins_from_user_config_value(&user_layer.config)
}
fn configured_plugins_from_user_config_value(
user_config: &toml::Value,
) -> HashMap<String, PluginConfig> {
let Some(plugins_value) = user_config.get("plugins") else {
return HashMap::new();
};
match plugins_value.clone().try_into() {
@@ -1345,6 +1333,60 @@ fn configured_plugins_from_stack(
}
}
fn configured_curated_plugin_ids(
configured_plugins: HashMap<String, PluginConfig>,
) -> Vec<PluginId> {
let mut configured_curated_plugin_ids = configured_plugins
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id) if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME => {
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid configured plugin key during curated sync setup"
);
None
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_curated_plugin_ids
}
fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<PluginId> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let user_config = match fs::read_to_string(&config_path) {
Ok(user_config) => user_config,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
warn!(
path = %config_path.display(),
error = %err,
"failed to read user config while refreshing curated plugin cache"
);
return Vec::new();
}
};
let user_config = match toml::from_str::<toml::Value>(&user_config) {
Ok(user_config) => user_config,
Err(err) => {
warn!(
path = %config_path.display(),
error = %err,
"failed to parse user config while refreshing curated plugin cache"
);
return Vec::new();
}
};
configured_curated_plugin_ids(configured_plugins_from_user_config_value(&user_config))
}
fn load_plugin(
config_name: String,
plugin: &PluginConfig,
+77 -12
View File
@@ -131,7 +131,10 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
}"#,
);
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
);
assert_eq!(
outcome.plugins(),
@@ -340,7 +343,10 @@ fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
);
assert_eq!(
outcome.plugins()[0].manifest_description.as_deref(),
@@ -375,7 +381,10 @@ fn capability_summary_truncates_overlong_plugin_descriptions() {
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
);
assert_eq!(
outcome.plugins()[0].manifest_description.as_deref(),
@@ -455,7 +464,10 @@ fn load_plugins_uses_manifest_configured_component_paths() {
}"#,
);
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
);
assert_eq!(
outcome.plugins()[0].skill_roots,
@@ -562,7 +574,10 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
}"#,
);
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
);
assert_eq!(
outcome.plugins()[0].skill_roots,
@@ -622,7 +637,12 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
}"#,
);
let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path());
let outcome = load_plugins_from_config(
&plugin_config_toml(
/*enabled*/ false, /*plugins_feature_enabled*/ true,
),
codex_home.path(),
);
assert_eq!(
outcome.plugins(),
@@ -831,7 +851,9 @@ fn load_plugins_returns_empty_when_feature_disabled() {
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml(true, false),
&plugin_config_toml(
/*enabled*/ true, /*plugins_feature_enabled*/ false,
),
);
let config = load_config_blocking(codex_home.path(), codex_home.path());
@@ -1326,7 +1348,7 @@ plugins = false
let config = load_config(tmp.path(), tmp.path()).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.sync_plugins_from_remote(&config, None, /*additive_only*/ false)
.sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false)
.await
.unwrap();
@@ -2110,10 +2132,13 @@ plugins = true
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 manager = PluginsManager::new_with_restriction_product(
tmp.path().to_path_buf(),
/*restriction_product*/ None,
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(&config, None)
.featured_plugin_ids_for_config(&config, /*auth*/ None)
.await
.unwrap();
@@ -2182,6 +2207,43 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren
);
}
#[test]
fn configured_curated_plugin_ids_from_codex_home_reads_latest_user_config() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."slack@openai-curated"]
enabled = true
[plugins."sample@debug"]
enabled = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path())
.into_iter()
.map(|plugin_id| plugin_id.as_key())
.collect::<Vec<_>>(),
vec!["slack@openai-curated".to_string()]
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path()),
Vec::<PluginId>::new()
);
}
#[test]
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
let tmp = tempfile::tempdir().unwrap();
@@ -2219,7 +2281,7 @@ fn load_plugins_ignores_project_config_files() {
);
write_file(
&project_root.join(".codex/config.toml"),
&plugin_config_toml(true, true),
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
);
let stack = ConfigLayerStack::new(
@@ -2227,7 +2289,10 @@ fn load_plugins_ignores_project_config_files() {
ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")).unwrap(),
},
toml::from_str(&plugin_config_toml(true, true)).expect("project config should parse"),
toml::from_str(&plugin_config_toml(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
))
.expect("project config should parse"),
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
+27 -19
View File
@@ -342,7 +342,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
AbsolutePathBuf::try_from(repo_root.clone()).unwrap(),
AbsolutePathBuf::try_from(nested_root).unwrap(),
],
None,
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
@@ -397,10 +397,12 @@ fn list_marketplaces_reads_marketplace_display_name() {
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap()
.marketplaces;
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].interface,
@@ -458,7 +460,7 @@ fn list_marketplaces_skips_marketplaces_that_fail_to_load() {
AbsolutePathBuf::try_from(valid_repo_root).unwrap(),
AbsolutePathBuf::try_from(invalid_repo_root).unwrap(),
],
None,
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
@@ -503,7 +505,7 @@ fn list_marketplaces_reports_marketplace_load_errors() {
AbsolutePathBuf::try_from(valid_repo_root).unwrap(),
AbsolutePathBuf::try_from(invalid_repo_root).unwrap(),
],
None,
/*home_dir*/ None,
)
.unwrap();
@@ -566,10 +568,12 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap()
.marketplaces;
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].policy.installation,
@@ -634,10 +638,12 @@ fn list_marketplaces_ignores_legacy_top_level_policy_fields() {
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap()
.marketplaces;
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].policy.installation,
@@ -690,10 +696,12 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap()
.marketplaces;
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].interface,
+143 -14
View File
@@ -7,8 +7,11 @@ use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC;
use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_METRIC;
use reqwest::Client;
use serde::Deserialize;
use tempfile::TempDir;
use tracing::info;
use tracing::warn;
use zip::ZipArchive;
@@ -27,6 +30,8 @@ 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);
// Keep this comfortably above a normal sync attempt so we do not race another Codex process.
const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60);
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);
@@ -63,14 +68,23 @@ fn sync_openai_plugins_repo_with_transport_overrides(
api_base_url: &str,
) -> Result<String, String> {
match sync_openai_plugins_repo_via_git(codex_home, git_binary) {
Ok(remote_sha) => Ok(remote_sha),
Ok(remote_sha) => {
emit_curated_plugins_startup_sync_metric("git", "success");
emit_curated_plugins_startup_sync_final_metric("git", "success");
Ok(remote_sha)
}
Err(err) => {
emit_curated_plugins_startup_sync_metric("git", "failure");
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)
let result = sync_openai_plugins_repo_via_http(codex_home, api_base_url);
let status = if result.is_ok() { "success" } else { "failure" };
emit_curated_plugins_startup_sync_metric("http", status);
emit_curated_plugins_startup_sync_final_metric("http", status);
result
}
}
}
@@ -85,7 +99,7 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu
return Ok(remote_sha);
}
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let staged_repo_dir = 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")
@@ -93,21 +107,21 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu
.arg("--depth")
.arg("1")
.arg("https://github.com/openai/plugins.git")
.arg(&cloned_repo_path),
.arg(staged_repo_dir.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)?;
let cloned_sha = git_head_sha(staged_repo_dir.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)?;
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
activate_curated_repo(&repo_path, staged_repo_dir)?;
write_curated_plugins_sha(&sha_path, &remote_sha)?;
Ok(remote_sha)
}
@@ -129,11 +143,11 @@ fn sync_openai_plugins_repo_via_http(
return Ok(remote_sha);
}
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let staged_repo_dir = 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)?;
extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?;
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
activate_curated_repo(&repo_path, staged_repo_dir)?;
write_curated_plugins_sha(&sha_path, &remote_sha)?;
Ok(remote_sha)
}
@@ -227,7 +241,7 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::
tokio::fs::write(marker_path, b"ok\n").await
}
fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf, String> {
fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<TempDir, String> {
let Some(parent) = repo_path.parent() else {
return Err(format!(
"failed to determine curated plugins parent directory for {}",
@@ -240,6 +254,7 @@ fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf,
parent.display()
)
})?;
remove_stale_curated_repo_temp_dirs(parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE);
let clone_dir = tempfile::Builder::new()
.prefix("plugins-clone-")
@@ -250,7 +265,120 @@ fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf,
parent.display()
)
})?;
Ok(clone_dir.keep())
Ok(clone_dir)
}
fn remove_stale_curated_repo_temp_dirs(parent: &Path, max_age: Duration) {
let entries = match std::fs::read_dir(parent) {
Ok(entries) => entries,
Err(err) => {
warn!(
error = %err,
parent = %parent.display(),
"failed to list curated plugins temp directory parent for stale cleanup"
);
return;
}
};
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(err) => {
warn!(
error = %err,
path = %entry.path().display(),
"failed to inspect curated plugins temp directory entry"
);
continue;
}
};
if !file_type.is_dir() {
continue;
}
let path = entry.path();
let is_plugins_clone_dir = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("plugins-clone-"));
if !is_plugins_clone_dir {
continue;
}
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(err) => {
warn!(
error = %err,
path = %path.display(),
"failed to read curated plugins temp directory metadata"
);
continue;
}
};
let modified = match metadata.modified() {
Ok(modified) => modified,
Err(err) => {
warn!(
error = %err,
path = %path.display(),
"failed to read curated plugins temp directory modification time"
);
continue;
}
};
let age = match modified.elapsed() {
Ok(age) => age,
Err(err) => {
warn!(
error = %err,
path = %path.display(),
"failed to compute curated plugins temp directory age"
);
continue;
}
};
if age < max_age {
continue;
}
if let Err(err) = std::fs::remove_dir_all(&path) {
warn!(
error = %err,
path = %path.display(),
"failed to remove stale curated plugins temp directory"
);
}
}
}
fn emit_curated_plugins_startup_sync_metric(transport: &'static str, status: &'static str) {
emit_curated_plugins_startup_sync_counter(
CURATED_PLUGINS_STARTUP_SYNC_METRIC,
transport,
status,
);
}
fn emit_curated_plugins_startup_sync_final_metric(transport: &'static str, status: &'static str) {
emit_curated_plugins_startup_sync_counter(
CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC,
transport,
status,
);
}
fn emit_curated_plugins_startup_sync_counter(
metric_name: &str,
transport: &'static str,
status: &'static str,
) {
let Some(metrics) = codex_otel::metrics::global() else {
return;
};
let tags = [("transport", transport), ("status", status)];
let _ = metrics.counter(metric_name, /*inc*/ 1, &tags);
}
fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> {
@@ -263,7 +391,8 @@ fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> {
))
}
fn activate_curated_repo(repo_path: &Path, staged_repo_path: &Path) -> Result<(), String> {
fn activate_curated_repo(repo_path: &Path, staged_repo_dir: TempDir) -> Result<(), String> {
let staged_repo_path = staged_repo_dir.path();
if repo_path.exists() {
let parent = repo_path.parent().ok_or_else(|| {
format!(
@@ -7,6 +7,7 @@ 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 std::path::Path;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
@@ -17,6 +18,21 @@ use wiremock::matchers::path;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
fn has_plugins_clone_dirs(codex_home: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(codex_home.join(".tmp")) else {
return false;
};
entries.flatten().any(|entry| {
let path = entry.path();
path.is_dir()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("plugins-clone-"))
})
}
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
@@ -38,6 +54,49 @@ fn read_curated_plugins_sha_reads_trimmed_sha_file() {
);
}
#[cfg(unix)]
#[test]
fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() {
use std::os::unix::ffi::OsStrExt;
use std::time::SystemTime;
fn set_dir_mtime(path: &Path, age: Duration) -> Result<(), Box<dyn std::error::Error>> {
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let modified_at = now.saturating_sub(age);
let tv_sec = i64::try_from(modified_at.as_secs())?;
let ts = libc::timespec { tv_sec, tv_nsec: 0 };
let times = [ts, ts];
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?;
let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
let tmp = tempdir().expect("tempdir");
let parent = tmp.path().join(".tmp");
let stale_clone_dir = parent.join("plugins-clone-stale");
let fresh_clone_dir = parent.join("plugins-clone-fresh");
let unrelated_dir = parent.join("plugins-cache");
std::fs::create_dir_all(&stale_clone_dir).expect("create stale clone dir");
std::fs::create_dir_all(&fresh_clone_dir).expect("create fresh clone dir");
std::fs::create_dir_all(&unrelated_dir).expect("create unrelated dir");
set_dir_mtime(
&stale_clone_dir,
CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE + Duration::from_secs(60),
)
.expect("age stale clone dir");
set_dir_mtime(&fresh_clone_dir, Duration::ZERO).expect("age fresh clone dir");
remove_stale_curated_repo_temp_dirs(&parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE);
assert!(!stale_clone_dir.exists());
assert!(fresh_clone_dir.is_dir());
assert!(unrelated_dir.is_dir());
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_prefers_git_when_available() {
@@ -229,6 +288,94 @@ exit 1
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-partial-fail-")
.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"
echo "fatal: early EOF" >&2
exit 128
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 err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path"))
.expect_err("git sync should fail");
assert!(err.contains("fatal: early EOF"));
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[tokio::test]
async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() {
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(b"not a zip archive".to_vec()),
)
.mount(&server)
.await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
let err = tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_via_http(tmp_path.as_path(), &server_uri)
})
.await
.expect("sync task should join")
.expect_err("http sync should fail");
assert!(err.contains("failed to open curated plugins zip archive"));
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[tokio::test]
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
let tmp = tempdir().expect("tempdir");
+43 -20
View File
@@ -75,7 +75,9 @@ async fn make_config_with_project_root_markers(
async fn no_doc_file_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let res = get_user_instructions(&make_config(&tmp, 4096, None).await).await;
let res =
get_user_instructions(&make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await)
.await;
assert!(
res.is_none(),
"Expected None when AGENTS.md is absent and no system instructions provided"
@@ -89,9 +91,10 @@ async fn doc_smaller_than_limit_is_returned() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap();
let res = get_user_instructions(&make_config(&tmp, 4096, None).await)
.await
.expect("doc expected");
let res =
get_user_instructions(&make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await)
.await
.expect("doc expected");
assert_eq!(
res, "hello world",
@@ -108,7 +111,7 @@ async fn doc_larger_than_limit_is_truncated() {
let huge = "A".repeat(LIMIT * 2); // 2 KiB
fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap();
let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await)
let res = get_user_instructions(&make_config(&tmp, LIMIT, /*instructions*/ None).await)
.await
.expect("doc expected");
@@ -137,7 +140,7 @@ async fn finds_doc_in_repo_root() {
std::fs::create_dir_all(&nested).unwrap();
// Build config pointing at the nested dir.
let mut cfg = make_config(&repo, 4096, None).await;
let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await;
cfg.cwd = nested.abs();
let res = get_user_instructions(&cfg).await.expect("doc expected");
@@ -150,7 +153,8 @@ async fn zero_byte_limit_disables_docs() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
let res = get_user_instructions(&make_config(&tmp, 0, None).await).await;
let res =
get_user_instructions(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await).await;
assert!(
res.is_none(),
"With limit 0 the function should return None"
@@ -160,7 +164,7 @@ async fn zero_byte_limit_disables_docs() {
#[tokio::test]
async fn js_repl_instructions_are_appended_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, 4096, None).await;
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.features
.enable(Feature::JsRepl)
.expect("test config should allow js_repl");
@@ -175,7 +179,7 @@ async fn js_repl_instructions_are_appended_when_enabled() {
#[tokio::test]
async fn js_repl_tools_only_instructions_are_feature_gated() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, 4096, None).await;
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let mut features = cfg.features.get().clone();
features
.enable(Feature::JsRepl)
@@ -194,7 +198,7 @@ async fn js_repl_tools_only_instructions_are_feature_gated() {
#[tokio::test]
async fn js_repl_image_detail_original_does_not_change_instructions() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, 4096, None).await;
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let mut features = cfg.features.get().clone();
features
.enable(Feature::JsRepl)
@@ -219,7 +223,7 @@ async fn merges_existing_instructions_with_project_doc() {
const INSTRUCTIONS: &str = "base instructions";
let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await)
let res = get_user_instructions(&make_config(&tmp, /*limit*/ 4096, Some(INSTRUCTIONS)).await)
.await
.expect("should produce a combined instruction string");
@@ -236,7 +240,8 @@ async fn keeps_existing_instructions_when_doc_missing() {
const INSTRUCTIONS: &str = "some instructions";
let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await).await;
let res =
get_user_instructions(&make_config(&tmp, /*limit*/ 4096, Some(INSTRUCTIONS)).await).await;
assert_eq!(res, Some(INSTRUCTIONS.to_string()));
}
@@ -262,7 +267,7 @@ async fn concatenates_root_and_cwd_docs() {
std::fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("AGENTS.md"), "crate doc").unwrap();
let mut cfg = make_config(&repo, 4096, None).await;
let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await;
cfg.cwd = nested.abs();
let res = get_user_instructions(&cfg).await.expect("doc expected");
@@ -279,7 +284,13 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
fs::create_dir_all(nested.join(".git")).unwrap();
fs::write(nested.join("AGENTS.md"), "child doc").unwrap();
let mut cfg = make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await;
let mut cfg = make_config_with_project_root_markers(
&root,
/*limit*/ 4096,
/*instructions*/ None,
&[".codex-root"],
)
.await;
cfg.cwd = nested.abs();
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
@@ -302,7 +313,7 @@ async fn agents_local_md_preferred() {
fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "versioned").unwrap();
fs::write(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME), "local").unwrap();
let cfg = make_config(&tmp, 4096, None).await;
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let res = get_user_instructions(&cfg)
.await
@@ -324,7 +335,13 @@ async fn uses_configured_fallback_when_agents_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("EXAMPLE.md"), "example instructions").unwrap();
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]).await;
let cfg = make_config_with_fallback(
&tmp,
/*limit*/ 4096,
/*instructions*/ None,
&["EXAMPLE.md"],
)
.await;
let res = get_user_instructions(&cfg)
.await
@@ -340,7 +357,13 @@ async fn agents_md_preferred_over_fallbacks() {
fs::write(tmp.path().join("AGENTS.md"), "primary").unwrap();
fs::write(tmp.path().join("EXAMPLE.md"), "secondary").unwrap();
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]).await;
let cfg = make_config_with_fallback(
&tmp,
/*limit*/ 4096,
/*instructions*/ None,
&["EXAMPLE.md", ".example.md"],
)
.await;
let res = get_user_instructions(&cfg)
.await
@@ -364,7 +387,7 @@ async fn skills_are_not_appended_to_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
let cfg = make_config(&tmp, 4096, None).await;
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
create_skill(
cfg.codex_home.clone(),
"pdf-processing",
@@ -380,7 +403,7 @@ async fn skills_are_not_appended_to_project_doc() {
#[tokio::test]
async fn apps_feature_does_not_emit_user_instructions_by_itself() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, 4096, None).await;
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.features
.enable(Feature::Apps)
.expect("test config should allow apps");
@@ -394,7 +417,7 @@ async fn apps_feature_does_not_append_to_project_doc_user_instructions() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
let mut cfg = make_config(&tmp, 4096, None).await;
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.features
.enable(Feature::Apps)
.expect("test config should allow apps");
+31
View File
@@ -0,0 +1,31 @@
use std::sync::Arc;
use crate::AuthManager;
use crate::model_provider_info::ModelProviderInfo;
/// Returns the provider-scoped auth manager when this provider uses command-backed auth.
///
/// Providers without custom auth continue using the caller-supplied base manager.
pub(crate) fn auth_manager_for_provider(
auth_manager: Option<Arc<AuthManager>>,
provider: &ModelProviderInfo,
) -> Option<Arc<AuthManager>> {
match provider.auth.clone() {
Some(config) => Some(AuthManager::external_bearer_only(config)),
None => auth_manager,
}
}
/// Returns an auth manager for request paths that always require authentication.
///
/// Providers with command-backed auth get a bearer-only manager; otherwise the caller's manager
/// is reused unchanged.
pub(crate) fn required_auth_manager_for_provider(
auth_manager: Arc<AuthManager>,
provider: &ModelProviderInfo,
) -> Arc<AuthManager> {
match provider.auth.clone() {
Some(config) => AuthManager::external_bearer_only(config),
None => auth_manager,
}
}
+3 -3
View File
@@ -47,7 +47,7 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
fn workspace_section_requires_meaningful_structure() {
let cwd = TempDir::new().expect("tempdir");
assert_eq!(
build_workspace_section_with_user_root(cwd.path(), None),
build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None),
None
);
}
@@ -58,8 +58,8 @@ fn workspace_section_includes_tree_when_entries_exist() {
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
let section =
build_workspace_section_with_user_root(cwd.path(), None).expect("workspace section");
let section = build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None)
.expect("workspace section");
assert!(section.contains("Working directory tree:"));
assert!(section.contains("- docs/"));
assert!(section.contains("- README.md"));
+6 -1
View File
@@ -452,7 +452,12 @@ async fn prepare_realtime_start(
params: ConversationStartParams,
) -> CodexResult<PreparedRealtimeConversationStart> {
let provider = sess.provider().await;
let auth = sess.services.auth_manager.auth().await;
let auth_manager = sess
.services
.model_client
.auth_manager()
.unwrap_or_else(|| Arc::clone(&sess.services.auth_manager));
let auth = auth_manager.auth().await;
let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?;
let mut api_provider = provider.to_api_provider(Some(crate::auth::AuthMode::ApiKey))?;
let config = sess.get_config().await;
+105 -13
View File
@@ -1,7 +1,9 @@
use codex_git_utils::merge_base_with_head;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_utils_template::Template;
use std::path::Path;
use std::sync::LazyLock;
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedReviewRequest {
@@ -12,12 +14,27 @@ pub struct ResolvedReviewRequest {
const UNCOMMITTED_PROMPT: &str = "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
const BASE_BRANCH_PROMPT_BACKUP: &str = "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.";
const BASE_BRANCH_PROMPT: &str = "Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
const BASE_BRANCH_PROMPT_BACKUP: &str = "Review the code changes against the base branch '{{branch}}'. Start by finding the merge diff between the current branch and {{branch}}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{{branch}}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {{branch}} branch. Provide prioritized, actionable findings.";
const BASE_BRANCH_PROMPT: &str = "Review the code changes against the base branch '{{base_branch}}'. The merge base commit for this comparison is {{merge_base_sha}}. Run `git diff {{merge_base_sha}}` to inspect the changes relative to {{base_branch}}. Provide prioritized, actionable findings.";
static BASE_BRANCH_PROMPT_BACKUP_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(BASE_BRANCH_PROMPT_BACKUP)
.unwrap_or_else(|err| panic!("base branch backup review prompt must parse: {err}"))
});
static BASE_BRANCH_PROMPT_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(BASE_BRANCH_PROMPT)
.unwrap_or_else(|err| panic!("base branch review prompt must parse: {err}"))
});
const COMMIT_PROMPT_WITH_TITLE: &str = "Review the code changes introduced by commit {sha} (\"{title}\"). Provide prioritized, actionable findings.";
const COMMIT_PROMPT: &str =
"Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
const COMMIT_PROMPT_WITH_TITLE: &str = "Review the code changes introduced by commit {{sha}} (\"{{title}}\"). Provide prioritized, actionable findings.";
const COMMIT_PROMPT: &str = "Review the code changes introduced by commit {{sha}}. Provide prioritized, actionable findings.";
static COMMIT_PROMPT_WITH_TITLE_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(COMMIT_PROMPT_WITH_TITLE)
.unwrap_or_else(|err| panic!("commit review prompt with title must parse: {err}"))
});
static COMMIT_PROMPT_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(COMMIT_PROMPT)
.unwrap_or_else(|err| panic!("commit review prompt must parse: {err}"))
});
pub fn resolve_review_request(
request: ReviewRequest,
@@ -41,20 +58,31 @@ pub fn review_prompt(target: &ReviewTarget, cwd: &Path) -> anyhow::Result<String
ReviewTarget::UncommittedChanges => Ok(UNCOMMITTED_PROMPT.to_string()),
ReviewTarget::BaseBranch { branch } => {
if let Some(commit) = merge_base_with_head(cwd, branch)? {
Ok(BASE_BRANCH_PROMPT
.replace("{baseBranch}", branch)
.replace("{mergeBaseSha}", &commit))
Ok(render_review_prompt(
&BASE_BRANCH_PROMPT_TEMPLATE,
[
("base_branch", branch.as_str()),
("merge_base_sha", commit.as_str()),
],
))
} else {
Ok(BASE_BRANCH_PROMPT_BACKUP.replace("{branch}", branch))
Ok(render_review_prompt(
&BASE_BRANCH_PROMPT_BACKUP_TEMPLATE,
[("branch", branch.as_str())],
))
}
}
ReviewTarget::Commit { sha, title } => {
if let Some(title) = title {
Ok(COMMIT_PROMPT_WITH_TITLE
.replace("{sha}", sha)
.replace("{title}", title))
Ok(render_review_prompt(
&COMMIT_PROMPT_WITH_TITLE_TEMPLATE,
[("sha", sha.as_str()), ("title", title.as_str())],
))
} else {
Ok(COMMIT_PROMPT.replace("{sha}", sha))
Ok(render_review_prompt(
&COMMIT_PROMPT_TEMPLATE,
[("sha", sha.as_str())],
))
}
}
ReviewTarget::Custom { instructions } => {
@@ -67,6 +95,15 @@ pub fn review_prompt(target: &ReviewTarget, cwd: &Path) -> anyhow::Result<String
}
}
fn render_review_prompt<'a, const N: usize>(
template: &Template,
variables: [(&'a str, &'a str); N],
) -> String {
template
.render(variables)
.unwrap_or_else(|err| panic!("review prompt template must render: {err}"))
}
pub fn user_facing_hint(target: &ReviewTarget) -> String {
match target {
ReviewTarget::UncommittedChanges => "current changes".to_string(),
@@ -91,3 +128,58 @@ impl From<ResolvedReviewRequest> for ReviewRequest {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn review_prompt_template_renders_base_branch_backup_variant() {
assert_eq!(
render_review_prompt(&BASE_BRANCH_PROMPT_BACKUP_TEMPLATE, [("branch", "main")]),
"Review the code changes against the base branch 'main'. Start by finding the merge diff between the current branch and main's upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"main@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the main branch. Provide prioritized, actionable findings."
);
}
#[test]
fn review_prompt_template_renders_base_branch_variant() {
assert_eq!(
render_review_prompt(
&BASE_BRANCH_PROMPT_TEMPLATE,
[("base_branch", "main"), ("merge_base_sha", "abc123")]
),
"Review the code changes against the base branch 'main'. The merge base commit for this comparison is abc123. Run `git diff abc123` to inspect the changes relative to main. Provide prioritized, actionable findings."
);
}
#[test]
fn review_prompt_template_renders_commit_variant() {
assert_eq!(
review_prompt(
&ReviewTarget::Commit {
sha: "deadbeef".to_string(),
title: None,
},
Path::new("."),
)
.expect("commit prompt should render"),
"Review the code changes introduced by commit deadbeef. Provide prioritized, actionable findings."
);
}
#[test]
fn review_prompt_template_renders_commit_variant_with_title() {
assert_eq!(
review_prompt(
&ReviewTarget::Commit {
sha: "deadbeef".to_string(),
title: Some("Fix bug".to_string()),
},
Path::new("."),
)
.expect("commit prompt should render"),
"Review the code changes introduced by commit deadbeef (\"Fix bug\"). Provide prioritized, actionable findings."
);
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ fn default_linux_sandbox_uses_platform_sandbox_tag() {
&SandboxPolicy::new_read_only_policy(),
WindowsSandboxLevel::Disabled,
);
let expected = get_platform_sandbox(false)
let expected = get_platform_sandbox(/*windows_sandbox_enabled*/ false)
.map(SandboxType::as_metric_tag)
.unwrap_or("none");
assert_eq!(actual, expected);
+18 -6
View File
@@ -269,9 +269,15 @@ async fn snapshot_shell_does_not_inherit_stdin() -> Result<()> {
"HOME=\"{home_display}\"; export HOME; {}",
bash_snapshot_script()
);
let output = run_script_with_timeout(&shell, &script, Duration::from_secs(2), true, home)
.await
.context("run snapshot command")?;
let output = run_script_with_timeout(
&shell,
&script,
Duration::from_secs(2),
/*use_login_shell*/ true,
home,
)
.await
.context("run snapshot command")?;
let read_status = fs::read_to_string(&read_status_path)
.await
.context("read stdin probe status")?;
@@ -307,9 +313,15 @@ async fn timed_out_snapshot_shell_is_terminated() -> Result<()> {
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true, dir.path())
.await
.expect_err("snapshot shell should time out");
let err = run_script_with_timeout(
&shell,
&script,
Duration::from_secs(1),
/*use_login_shell*/ true,
dir.path(),
)
.await
.expect_err("snapshot shell should time out");
assert!(
err.to_string().contains("timed out"),
"expected timeout error, got {err:?}"
+43 -19
View File
@@ -5,7 +5,7 @@ use std::process::Command;
#[test]
#[cfg(target_os = "macos")]
fn detects_zsh() {
let zsh_shell = get_shell(ShellType::Zsh, None).unwrap();
let zsh_shell = get_shell(ShellType::Zsh, /*path*/ None).unwrap();
let shell_path = zsh_shell.shell_path;
@@ -24,7 +24,7 @@ fn fish_fallback_to_zsh() {
#[test]
fn detects_bash() {
let bash_shell = get_shell(ShellType::Bash, None).unwrap();
let bash_shell = get_shell(ShellType::Bash, /*path*/ None).unwrap();
let shell_path = bash_shell.shell_path;
assert!(
@@ -35,7 +35,7 @@ fn detects_bash() {
#[test]
fn detects_sh() {
let sh_shell = get_shell(ShellType::Sh, None).unwrap();
let sh_shell = get_shell(ShellType::Sh, /*path*/ None).unwrap();
let shell_path = sh_shell.shell_path;
assert!(
shell_path.file_name().and_then(|name| name.to_str()) == Some("sh"),
@@ -48,23 +48,47 @@ fn can_run_on_shell_test() {
let cmd = "echo \"Works\"";
if cfg!(windows) {
assert!(shell_works(
get_shell(ShellType::PowerShell, None),
get_shell(ShellType::PowerShell, /*path*/ None),
"Out-String 'Works'",
true,
/*required*/ true,
));
assert!(shell_works(
get_shell(ShellType::Cmd, /*path*/ None),
cmd,
/*required*/ true,
));
assert!(shell_works(
Some(ultimate_fallback_shell()),
cmd,
/*required*/ true
));
assert!(shell_works(get_shell(ShellType::Cmd, None), cmd, true,));
assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true));
} else {
assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true));
assert!(shell_works(get_shell(ShellType::Zsh, None), cmd, false));
assert!(shell_works(get_shell(ShellType::Bash, None), cmd, true));
assert!(shell_works(get_shell(ShellType::Sh, None), cmd, true));
assert!(shell_works(
Some(ultimate_fallback_shell()),
cmd,
/*required*/ true
));
assert!(shell_works(
get_shell(ShellType::Zsh, /*path*/ None),
cmd,
/*required*/ false
));
assert!(shell_works(
get_shell(ShellType::Bash, /*path*/ None),
cmd,
/*required*/ true
));
assert!(shell_works(
get_shell(ShellType::Sh, /*path*/ None),
cmd,
/*required*/ true
));
}
}
fn shell_works(shell: Option<Shell>, command: &str, required: bool) -> bool {
if let Some(shell) = shell {
let args = shell.derive_exec_args(command, false);
let args = shell.derive_exec_args(command, /*use_login_shell*/ false);
let output = Command::new(args[0].clone())
.args(&args[1..])
.output()
@@ -85,11 +109,11 @@ fn derive_exec_args() {
shell_snapshot: empty_shell_snapshot_receiver(),
};
assert_eq!(
test_bash_shell.derive_exec_args("echo hello", false),
test_bash_shell.derive_exec_args("echo hello", /*use_login_shell*/ false),
vec!["/bin/bash", "-c", "echo hello"]
);
assert_eq!(
test_bash_shell.derive_exec_args("echo hello", true),
test_bash_shell.derive_exec_args("echo hello", /*use_login_shell*/ true),
vec!["/bin/bash", "-lc", "echo hello"]
);
@@ -99,11 +123,11 @@ fn derive_exec_args() {
shell_snapshot: empty_shell_snapshot_receiver(),
};
assert_eq!(
test_zsh_shell.derive_exec_args("echo hello", false),
test_zsh_shell.derive_exec_args("echo hello", /*use_login_shell*/ false),
vec!["/bin/zsh", "-c", "echo hello"]
);
assert_eq!(
test_zsh_shell.derive_exec_args("echo hello", true),
test_zsh_shell.derive_exec_args("echo hello", /*use_login_shell*/ true),
vec!["/bin/zsh", "-lc", "echo hello"]
);
@@ -113,11 +137,11 @@ fn derive_exec_args() {
shell_snapshot: empty_shell_snapshot_receiver(),
};
assert_eq!(
test_powershell_shell.derive_exec_args("echo hello", false),
test_powershell_shell.derive_exec_args("echo hello", /*use_login_shell*/ false),
vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"]
);
assert_eq!(
test_powershell_shell.derive_exec_args("echo hello", true),
test_powershell_shell.derive_exec_args("echo hello", /*use_login_shell*/ true),
vec!["pwsh.exe", "-Command", "echo hello"]
);
}
@@ -161,7 +185,7 @@ fn finds_powershell() {
return;
}
let powershell_shell = get_shell(ShellType::PowerShell, None).unwrap();
let powershell_shell = get_shell(ShellType::PowerShell, /*path*/ None).unwrap();
let shell_path = powershell_shell.shell_path;
assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe"));
+1 -1
View File
@@ -102,7 +102,7 @@ mod tests {
let mut rx = skills_watcher.subscribe();
let _registration = skills_watcher
.subscriber
.register_path(PathBuf::from("/tmp/skill"), true);
.register_path(PathBuf::from("/tmp/skill"), /*recursive*/ true);
file_watcher
.send_paths_for_test(vec![PathBuf::from("/tmp/skill/SKILL.md")])
-4
View File
@@ -198,10 +198,6 @@ impl TurnState {
}
}
pub(crate) fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
self.pending_input.clone()
}
pub(crate) fn has_pending_input(&self) -> bool {
!self.pending_input.is_empty()
}
+13 -6
View File
@@ -28,9 +28,10 @@ async fn handle_non_tool_response_item_strips_citations_from_assistant_message()
"hello<oai-mem-citation><citation_entries>\nMEMORY.md:1-2|note=[x]\n</citation_entries>\n<rollout_ids>\n019cc2ea-1dff-7902-8d40-c8f6e5d83cc4\n</rollout_ids></oai-mem-citation> world",
);
let turn_item = handle_non_tool_response_item(&session, &turn_context, &item, false)
.await
.expect("assistant message should parse");
let turn_item =
handle_non_tool_response_item(&session, &turn_context, &item, /*plan_mode*/ false)
.await
.expect("assistant message should parse");
let TurnItem::AgentMessage(agent_message) = turn_item else {
panic!("expected agent message");
@@ -60,7 +61,7 @@ fn last_assistant_message_from_item_strips_citations_and_plan_blocks() {
"before<oai-mem-citation>doc1</oai-mem-citation>\n<proposed_plan>\n- x\n</proposed_plan>\nafter",
);
let message = last_assistant_message_from_item(&item, true)
let message = last_assistant_message_from_item(&item, /*plan_mode*/ true)
.expect("assistant text should remain after stripping");
assert_eq!(message, "before\nafter");
@@ -70,14 +71,20 @@ fn last_assistant_message_from_item_strips_citations_and_plan_blocks() {
fn last_assistant_message_from_item_returns_none_for_citation_only_message() {
let item = assistant_output_text("<oai-mem-citation>doc1</oai-mem-citation>");
assert_eq!(last_assistant_message_from_item(&item, false), None);
assert_eq!(
last_assistant_message_from_item(&item, /*plan_mode*/ false),
None
);
}
#[test]
fn last_assistant_message_from_item_returns_none_for_plan_only_hidden_message() {
let item = assistant_output_text("<proposed_plan>\n- x\n</proposed_plan>");
assert_eq!(last_assistant_message_from_item(&item, true), None);
assert_eq!(
last_assistant_message_from_item(&item, /*plan_mode*/ true),
None
);
}
#[tokio::test]
@@ -27,5 +27,8 @@ fn large_untracked_warning_disabled_when_threshold_disabled() {
ignored_untracked_files: Vec::new(),
};
assert_eq!(format_large_untracked_warning(None, &report), None);
assert_eq!(
format_large_untracked_warning(/*ignore_large_untracked_dirs*/ None, &report),
None
);
}
+114 -65
View File
@@ -58,7 +58,7 @@ pub(crate) use user_shell::UserShellCommandTask;
pub(crate) use user_shell::execute_user_shell_command;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed.";
/// Shared model-visible marker used by both the real interrupt path and
/// interrupted fork snapshots.
@@ -191,60 +191,68 @@ impl Session {
let cancellation_token = CancellationToken::new();
let done = Arc::new(Notify::new());
let done_clone = Arc::clone(&done);
let handle = {
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
let ctx = Arc::clone(&turn_context);
let task_for_run = Arc::clone(&task);
let task_cancellation_token = cancellation_token.child_token();
// Task-owned turn spans keep a core-owned span open for the
// full task lifecycle after the submission dispatch span ends.
let task_span = info_span!(
"turn",
otel.name = span_name,
thread.id = %self.conversation_id,
turn.id = %turn_context.sub_id,
model = %turn_context.model_info.slug,
);
tokio::spawn(
async move {
let ctx_for_finish = Arc::clone(&ctx);
let last_agent_message = task_for_run
.run(
Arc::clone(&session_ctx),
ctx,
input,
task_cancellation_token.child_token(),
)
.await;
let sess = session_ctx.clone_session();
sess.flush_rollout().await;
if !task_cancellation_token.is_cancelled() {
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message)
.await;
}
done_clone.notify_waiters();
}
.instrument(task_span),
)
};
let queued_response_items = self.take_queued_response_items_for_next_turn().await;
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
let mut turn_state = turn.turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
for item in queued_response_items {
turn_state.push_pending_input(item);
let mailbox_items = self.get_pending_input().await;
let turn_state = {
let mut active = self.active_turn.lock().await;
let turn = active.get_or_insert_with(ActiveTurn::default);
debug_assert!(turn.tasks.is_empty());
Arc::clone(&turn.turn_state)
};
{
let mut turn_state = turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
for item in queued_response_items {
turn_state.push_pending_input(item);
}
for item in mailbox_items {
turn_state.push_pending_input(item);
}
}
drop(turn_state);
let mut active = self.active_turn.lock().await;
let turn = active.get_or_insert_with(ActiveTurn::default);
debug_assert!(turn.tasks.is_empty());
let done_clone = Arc::clone(&done);
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
let ctx = Arc::clone(&turn_context);
let task_for_run = Arc::clone(&task);
let task_cancellation_token = cancellation_token.child_token();
// Task-owned turn spans keep a core-owned span open for the
// full task lifecycle after the submission dispatch span ends.
let task_span = info_span!(
"turn",
otel.name = span_name,
thread.id = %self.conversation_id,
turn.id = %turn_context.sub_id,
model = %turn_context.model_info.slug,
);
let handle = tokio::spawn(
async move {
let ctx_for_finish = Arc::clone(&ctx);
let last_agent_message = task_for_run
.run(
Arc::clone(&session_ctx),
ctx,
input,
task_cancellation_token.child_token(),
)
.await;
let sess = session_ctx.clone_session();
sess.flush_rollout().await;
if !task_cancellation_token.is_cancelled() {
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message)
.await;
}
done_clone.notify_waiters();
}
.instrument(task_span),
);
let timer = turn_context
.session_telemetry
.start_timer(TURN_E2E_DURATION_METRIC, &[])
.ok();
let running_task = RunningTask {
done,
handle: Arc::new(AbortOnDropHandle::new(handle)),
@@ -255,19 +263,44 @@ impl Session {
_timer: timer,
};
turn.add_task(running_task);
*active = Some(turn);
}
pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc<Self>) {
if !self.has_queued_response_items_for_next_turn().await {
/// Starts a regular turn when the session is idle and pending work is waiting.
///
/// Pending work currently includes queued next-turn items and mailbox mail marked with
/// `trigger_turn`.
///
/// This helper generates a fresh sub-id for the synthetic turn before delegating to the
/// explicit-sub-id variant.
pub(crate) async fn maybe_start_turn_for_pending_work(self: &Arc<Self>) {
self.maybe_start_turn_for_pending_work_with_sub_id(uuid::Uuid::new_v4().to_string())
.await;
}
/// Starts a regular turn with the provided sub-id when pending work should wake an idle
/// session.
///
/// The turn is created only when there are queued next-turn items or mailbox mail marked with
/// `trigger_turn`, and only if the session is currently idle.
pub(crate) async fn maybe_start_turn_for_pending_work_with_sub_id(
self: &Arc<Self>,
sub_id: String,
) {
if !self.has_queued_response_items_for_next_turn().await
&& !self.has_trigger_turn_mailbox_items().await
{
return;
}
if self.active_turn.lock().await.is_some() {
return;
{
let mut active_turn = self.active_turn.lock().await;
if active_turn.is_some() {
return;
}
*active_turn = Some(ActiveTurn::default());
}
let turn_context = self.new_default_turn().await;
let turn_context = self.new_default_turn_with_sub_id(sub_id).await;
self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
self.start_task(turn_context, Vec::new(), RegularTask::new())
@@ -284,7 +317,7 @@ impl Session {
active_turn.clear_pending().await;
}
if reason == TurnAbortReason::Interrupted {
self.ensure_task_for_queued_response_items().await;
self.maybe_start_turn_for_pending_work().await;
}
}
@@ -297,24 +330,31 @@ impl Session {
.turn_metadata_state
.cancel_git_enrichment_task();
let mut active = self.active_turn.lock().await;
let mut pending_input = Vec::<ResponseInputItem>::new();
let mut should_clear_active_turn = false;
let mut token_usage_at_turn_start = None;
let mut turn_tool_calls = 0_u64;
if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
let mut ts = at.turn_state.lock().await;
let turn_state = {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
should_clear_active_turn = true;
let turn_state = Arc::clone(&at.turn_state);
if should_clear_active_turn {
*active = None;
}
Some(turn_state)
} else {
None
}
};
if let Some(turn_state) = turn_state {
let mut ts = turn_state.lock().await;
pending_input = ts.take_pending_input();
turn_tool_calls = ts.tool_calls;
token_usage_at_turn_start = Some(ts.token_usage_at_turn_start.clone());
should_clear_active_turn = true;
}
if should_clear_active_turn {
*active = None;
}
drop(active);
if !pending_input.is_empty() {
for pending_input_item in pending_input {
match inspect_pending_input(self, &turn_context, pending_input_item).await {
@@ -413,6 +453,15 @@ impl Session {
last_agent_message,
});
self.send_event(turn_context.as_ref(), event).await;
if should_clear_active_turn {
let session = Arc::clone(self);
let _scheduler = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current().block_on(async move {
session.maybe_start_turn_for_pending_work().await;
});
});
}
}
async fn take_active_turn(&self) -> Option<ActiveTurn> {
+14 -6
View File
@@ -25,11 +25,11 @@ fn test_session_telemetry() -> SessionTelemetry {
ThreadId::new(),
"gpt-5.1",
"gpt-5.1",
None,
None,
None,
/*account_id*/ None,
/*account_email*/ None,
/*auth_mode*/ None,
"test_originator".to_string(),
false,
/*log_user_prompts*/ false,
"tty".to_string(),
SessionSource::Cli,
)
@@ -75,7 +75,11 @@ fn metric_point(resource_metrics: &ResourceMetrics) -> (BTreeMap<String, String>
fn emit_turn_network_proxy_metric_records_active_turn() {
let session_telemetry = test_session_telemetry();
emit_turn_network_proxy_metric(&session_telemetry, true, ("tmp_mem_enabled", "true"));
emit_turn_network_proxy_metric(
&session_telemetry,
/*network_proxy_active*/ true,
("tmp_mem_enabled", "true"),
);
let snapshot = session_telemetry
.snapshot_metrics()
@@ -96,7 +100,11 @@ fn emit_turn_network_proxy_metric_records_active_turn() {
fn emit_turn_network_proxy_metric_records_inactive_turn() {
let session_telemetry = test_session_telemetry();
emit_turn_network_proxy_metric(&session_telemetry, false, ("tmp_mem_enabled", "false"));
emit_turn_network_proxy_metric(
&session_telemetry,
/*network_proxy_active*/ false,
("tmp_mem_enabled", "false"),
);
let snapshot = session_telemetry
.snapshot_metrics()
+52 -3
View File
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::sync::Arc;
use async_trait::async_trait;
@@ -14,6 +15,7 @@ use codex_protocol::protocol::ExitedReviewModeEvent;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::protocol::SubAgentSource;
use codex_utils_template::Template;
use tokio_util::sync::CancellationToken;
use crate::codex::Session;
@@ -25,10 +27,18 @@ use crate::review_format::render_review_output_text;
use crate::state::TaskKind;
use codex_features::Feature;
use codex_protocol::user_input::UserInput;
use std::sync::LazyLock;
use super::SessionTask;
use super::SessionTaskContext;
static REVIEW_EXIT_SUCCESS_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
let normalized =
normalize_review_template_line_endings(crate::client_common::REVIEW_EXIT_SUCCESS_TMPL);
Template::parse(normalized.as_ref())
.unwrap_or_else(|err| panic!("review exit success template must parse: {err}"))
});
#[derive(Clone, Copy)]
pub(crate) struct ReviewTask;
@@ -220,12 +230,14 @@ pub(crate) async fn exit_review_mode(
let block = format_review_findings_block(&out.findings, /*selection*/ None);
findings_str.push_str(&format!("\n{block}"));
}
let rendered =
crate::client_common::REVIEW_EXIT_SUCCESS_TMPL.replace("{results}", &findings_str);
let rendered = render_review_exit_success(&findings_str);
let assistant_message = render_review_output_text(&out);
(rendered, assistant_message)
} else {
let rendered = crate::client_common::REVIEW_EXIT_INTERRUPTED_TMPL.to_string();
let rendered = normalize_review_template_line_endings(
crate::client_common::REVIEW_EXIT_INTERRUPTED_TMPL,
)
.into_owned();
let assistant_message =
"Review was interrupted. Please re-run /review and wait for it to complete."
.to_string();
@@ -271,3 +283,40 @@ pub(crate) async fn exit_review_mode(
// file creation + git metadata collection cannot delay client-facing items.
session.ensure_rollout_materialized().await;
}
fn render_review_exit_success(results: &str) -> String {
REVIEW_EXIT_SUCCESS_TEMPLATE
.render([("results", results)])
.unwrap_or_else(|err| panic!("review exit success template must render: {err}"))
}
fn normalize_review_template_line_endings(template: &str) -> Cow<'_, str> {
if template.contains('\r') {
Cow::Owned(template.replace("\r\n", "\n").replace('\r', "\n"))
} else {
Cow::Borrowed(template)
}
}
#[cfg(test)]
mod tests {
use super::normalize_review_template_line_endings;
use super::render_review_exit_success;
use pretty_assertions::assert_eq;
#[test]
fn render_review_exit_success_replaces_results_placeholder() {
assert_eq!(
render_review_exit_success("Finding A\nFinding B"),
"<user_action>\n <context>User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve.</context>\n <action>review</action>\n <results>\n Finding A\nFinding B\n </results>\n </user_action>\n"
);
}
#[test]
fn normalize_review_template_line_endings_rewrites_crlf() {
assert_eq!(
normalize_review_template_line_endings("<user_action>\r\n <results>\r\n None.\r\n"),
"<user_action>\n <results>\n None.\n"
);
}
}
+3 -3
View File
@@ -75,7 +75,7 @@ fn truncates_before_requested_user_message() {
.collect();
let truncated = truncate_before_nth_user_message(
InitialHistory::Forked(initial),
1,
/*n*/ 1,
&SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
@@ -100,7 +100,7 @@ fn truncates_before_requested_user_message() {
.collect();
let truncated2 = truncate_before_nth_user_message(
InitialHistory::Forked(initial2.clone()),
2,
/*n*/ 2,
&SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
@@ -210,7 +210,7 @@ async fn ignores_session_prefix_messages_when_truncating() {
let truncated = truncate_before_nth_user_message(
InitialHistory::Forked(rollout_items),
1,
/*n*/ 1,
&SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
@@ -3,10 +3,12 @@
//! In core, "user turns" are detected by scanning `ResponseItem::Message` items and
//! interpreting them via `event_mapping::parse_turn_item(...)`.
use crate::context_manager::is_user_turn_boundary;
use crate::event_mapping;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::RolloutItem;
/// Return the indices of user message boundaries in a rollout.
@@ -40,6 +42,52 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec<us
user_positions
}
/// Return the indices of fork-turn boundaries in a rollout.
///
/// A fork-turn boundary is either:
/// - a real user message boundary, or
/// - an assistant inter-agent envelope whose parsed `trigger_turn` is `true`.
///
/// Like `user_message_positions_in_rollout`, this applies `ThreadRolledBack` markers so indexing
/// reflects the effective post-rollback history. Rollback counts instruction turns, so a rollback
/// removes the stale suffix starting at the earliest rolled-back instruction-turn boundary instead
/// of simply truncating the mixed fork-boundary list.
pub(crate) fn fork_turn_positions_in_rollout(items: &[RolloutItem]) -> Vec<usize> {
let mut rollback_turn_positions = Vec::new();
let mut fork_turn_positions = Vec::new();
for (idx, item) in items.iter().enumerate() {
match item {
RolloutItem::ResponseItem(item) => {
if is_user_turn_boundary(item) {
rollback_turn_positions.push(idx);
}
if is_real_user_message_boundary(item) || is_trigger_turn_boundary(item) {
fork_turn_positions.push(idx);
}
}
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX);
if num_turns == 0 {
continue;
}
let Some(rollback_start_idx) = rollback_turn_positions
.len()
.checked_sub(num_turns)
.map(|rollback_start| rollback_turn_positions[rollback_start])
.or_else(|| rollback_turn_positions.first().copied())
else {
continue;
};
let new_rollback_len = rollback_turn_positions.len().saturating_sub(num_turns);
rollback_turn_positions.truncate(new_rollback_len);
fork_turn_positions.retain(|position| *position < rollback_start_idx);
}
_ => {}
}
}
fork_turn_positions
}
/// Return a prefix of `items` obtained by cutting strictly before the nth user message.
///
/// The boundary index is 0-based from the start of `items` (so `n_from_start = 0` returns
@@ -68,6 +116,43 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
items[..cut_idx].to_vec()
}
/// Return a suffix of `items` that keeps the last `n_from_end` fork turns.
///
/// If fewer than or equal to `n_from_end` fork turns exist, this returns the full rollout.
pub(crate) fn truncate_rollout_to_last_n_fork_turns(
items: &[RolloutItem],
n_from_end: usize,
) -> Vec<RolloutItem> {
if n_from_end == 0 {
return Vec::new();
}
let fork_turn_positions = fork_turn_positions_in_rollout(items);
if fork_turn_positions.len() <= n_from_end {
return items.to_vec();
}
let keep_idx = fork_turn_positions[fork_turn_positions.len() - n_from_end];
items[keep_idx..].to_vec()
}
fn is_real_user_message_boundary(item: &ResponseItem) -> bool {
matches!(
event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_))
)
}
fn is_trigger_turn_boundary(item: &ResponseItem) -> bool {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
role == "assistant"
&& InterAgentCommunication::from_message_content(content)
.is_some_and(|communication| communication.trigger_turn)
}
#[cfg(test)]
#[path = "thread_rollout_truncation_tests.rs"]
mod tests;
@@ -1,7 +1,9 @@
use super::*;
use crate::codex::make_session_and_context;
use codex_protocol::AgentPath;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ThreadRolledBackEvent;
use pretty_assertions::assert_eq;
@@ -29,6 +31,17 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
}
fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem {
let communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
text.to_string(),
trigger_turn,
);
communication.to_response_input_item().into()
}
#[test]
fn truncates_rollout_from_start_before_nth_user_only() {
let items = [
@@ -61,7 +74,8 @@ fn truncates_rollout_from_start_before_nth_user_only() {
.map(RolloutItem::ResponseItem)
.collect();
let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout, 1);
let truncated =
truncate_rollout_before_nth_user_message_from_start(&rollout, /*n_from_start*/ 1);
let expected = vec![
RolloutItem::ResponseItem(items[0].clone()),
RolloutItem::ResponseItem(items[1].clone()),
@@ -72,7 +86,8 @@ fn truncates_rollout_from_start_before_nth_user_only() {
serde_json::to_value(&expected).unwrap()
);
let truncated2 = truncate_rollout_before_nth_user_message_from_start(&rollout, 2);
let truncated2 =
truncate_rollout_before_nth_user_message_from_start(&rollout, /*n_from_start*/ 2);
assert_eq!(
serde_json::to_value(&truncated2).unwrap(),
serde_json::to_value(&rollout).unwrap()
@@ -113,7 +128,10 @@ fn truncates_rollout_from_start_applies_thread_rollback_markers() {
// Effective user history after applying rollback(1) is: u1, u3, u4.
// So n_from_start=2 should cut before u4 (not u3).
let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 2);
let truncated = truncate_rollout_before_nth_user_message_from_start(
&rollout_items,
/*n_from_start*/ 2,
);
let expected = rollout_items[..7].to_vec();
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
@@ -136,7 +154,10 @@ async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
.map(RolloutItem::ResponseItem)
.collect();
let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 1);
let truncated = truncate_rollout_before_nth_user_message_from_start(
&rollout_items,
/*n_from_start*/ 1,
);
let expected: Vec<RolloutItem> = vec![
RolloutItem::ResponseItem(items[0].clone()),
RolloutItem::ResponseItem(items[1].clone()),
@@ -149,3 +170,149 @@ async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
serde_json::to_value(&expected).unwrap()
);
}
#[test]
fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() {
let rollout = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::ResponseItem(inter_agent_msg(
"queued message",
/*trigger_turn*/ false,
)),
RolloutItem::ResponseItem(assistant_msg("a2")),
RolloutItem::ResponseItem(inter_agent_msg(
"triggered task",
/*trigger_turn*/ true,
)),
RolloutItem::ResponseItem(assistant_msg("a3")),
RolloutItem::ResponseItem(user_msg("u2")),
RolloutItem::ResponseItem(assistant_msg("a4")),
];
let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2);
let expected = rollout[4..].to_vec();
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&expected).unwrap()
);
}
#[test]
fn truncates_rollout_to_last_n_fork_turns_applies_thread_rollback_markers() {
let rollout = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::ResponseItem(inter_agent_msg(
"triggered task",
/*trigger_turn*/ true,
)),
RolloutItem::ResponseItem(assistant_msg("a2")),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
num_turns: 1,
})),
RolloutItem::ResponseItem(user_msg("u2")),
RolloutItem::ResponseItem(assistant_msg("a3")),
];
let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2);
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&rollout).unwrap()
);
}
#[test]
fn fork_turn_positions_ignore_zero_turn_rollback_markers() {
let rollout = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(inter_agent_msg(
"triggered task",
/*trigger_turn*/ true,
)),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
num_turns: 0,
})),
RolloutItem::ResponseItem(user_msg("u2")),
];
assert_eq!(fork_turn_positions_in_rollout(&rollout), vec![0, 1, 3]);
}
#[test]
fn truncates_rollout_to_last_n_fork_turns_discards_trigger_boundaries_in_rolled_back_suffix() {
let rollout = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(user_msg("u2")),
RolloutItem::ResponseItem(inter_agent_msg(
"triggered task",
/*trigger_turn*/ true,
)),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
num_turns: 1,
})),
RolloutItem::ResponseItem(user_msg("u3")),
RolloutItem::ResponseItem(assistant_msg("a2")),
];
let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2);
let expected = rollout[1..].to_vec();
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&expected).unwrap()
);
}
#[test]
fn truncates_rollout_to_last_n_fork_turns_discards_rolled_back_assistant_instruction_turns() {
let rollout = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::ResponseItem(inter_agent_msg(
"triggered task 1",
/*trigger_turn*/ true,
)),
RolloutItem::ResponseItem(assistant_msg("a2")),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
num_turns: 1,
})),
RolloutItem::ResponseItem(inter_agent_msg(
"triggered task 2",
/*trigger_turn*/ true,
)),
RolloutItem::ResponseItem(assistant_msg("a3")),
];
let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 1);
let expected = rollout[5..].to_vec();
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&expected).unwrap()
);
}
#[test]
fn truncates_rollout_to_last_n_fork_turns_keeps_full_rollout_when_n_is_large() {
let rollout = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::ResponseItem(inter_agent_msg(
"triggered task",
/*trigger_turn*/ true,
)),
RolloutItem::ResponseItem(assistant_msg("a2")),
];
let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 10);
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&rollout).unwrap()
);
}
+2 -31
View File
@@ -19,7 +19,6 @@ use crate::codex::Session;
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::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolPayload;
@@ -29,6 +28,7 @@ use crate::tools::router::ToolCallSource;
use crate::tools::router::ToolRouterParams;
use crate::unified_exec::resolve_max_tokens;
use codex_features::Feature;
use codex_tools::tool_spec_to_code_mode_tool_definition;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
@@ -247,42 +247,13 @@ pub(super) async fn build_enabled_tools(
let mut out = router
.specs()
.into_iter()
.map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true))
.filter_map(enabled_tool_from_spec)
.filter_map(|spec| tool_spec_to_code_mode_tool_definition(&spec))
.collect::<Vec<_>>();
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<codex_code_mode::ToolDefinition> {
let tool_name = spec.name().to_string();
if !codex_code_mode::is_code_mode_nested_tool(&tool_name) {
return None;
}
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 { .. } => None,
}
}
async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools();
let mcp_tools = exec
@@ -1,49 +0,0 @@
use crate::client_common::tools::ToolSpec;
#[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 {
return spec;
}
match spec {
ToolSpec::Function(mut tool) => {
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 != codex_code_mode::PUBLIC_TOOL_NAME {
tool.description = codex_code_mode::append_code_mode_sample(
&tool.description,
&tool.name,
"input",
"string".to_string(),
"unknown".to_string(),
);
}
ToolSpec::Freeform(tool)
}
other => other,
}
}
@@ -1,104 +0,0 @@
use super::append_code_mode_sample;
use super::render_json_schema_to_typescript;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn render_json_schema_to_typescript_renders_object_properties() {
let schema = json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"recursive": {"type": "boolean"}
},
"required": ["path"],
"additionalProperties": false
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ path: string; recursive?: boolean; }"
);
}
#[test]
fn render_json_schema_to_typescript_renders_anyof_unions() {
let schema = json!({
"anyOf": [
{"const": "pending"},
{"const": "done"},
{"type": "number"}
]
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"\"pending\" | \"done\" | number"
);
}
#[test]
fn render_json_schema_to_typescript_renders_additional_properties() {
let schema = json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"}
}
},
"additionalProperties": {"type": "integer"}
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ tags?: Array<string>; [key: string]: number; }"
);
}
#[test]
fn render_json_schema_to_typescript_sorts_object_properties() {
let schema = json!({
"type": "object",
"properties": {
"structuredContent": {"type": "string"},
"_meta": {"type": "string"},
"isError": {"type": "boolean"},
"content": {"type": "array", "items": {"type": "string"}}
},
"required": ["content"]
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ _meta?: string; content: Array<string>; isError?: boolean; structuredContent?: string; }"
);
}
#[test]
fn append_code_mode_sample_uses_global_tools_for_valid_identifiers() {
assert_eq!(
append_code_mode_sample(
"desc",
"mcp__ologs__get_profile",
"args",
"{ foo: string }".to_string(),
"unknown".to_string(),
),
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__ologs__get_profile(args: { foo: string }): Promise<unknown>; };\n```"
);
}
#[test]
fn append_code_mode_sample_normalizes_invalid_identifiers() {
assert_eq!(
append_code_mode_sample(
"desc",
"mcp__rmcp__echo-tool",
"args",
"{ foo: string }".to_string(),
"unknown".to_string(),
),
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__rmcp__echo_tool(args: { foo: string }): Promise<unknown>; };\n```"
);
}
-134
View File
@@ -1,134 +0,0 @@
use crate::plugins::PluginCapabilitySummary;
use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde::Serialize;
const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui";
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DiscoverableToolType {
Connector,
Plugin,
}
impl DiscoverableToolType {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Connector => "connector",
Self::Plugin => "plugin",
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DiscoverableToolAction {
Install,
Enable,
}
impl DiscoverableToolAction {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Enable => "enable",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum DiscoverableTool {
Connector(Box<AppInfo>),
Plugin(Box<DiscoverablePluginInfo>),
}
impl DiscoverableTool {
pub(crate) fn tool_type(&self) -> DiscoverableToolType {
match self {
Self::Connector(_) => DiscoverableToolType::Connector,
Self::Plugin(_) => DiscoverableToolType::Plugin,
}
}
pub(crate) fn id(&self) -> &str {
match self {
Self::Connector(connector) => connector.id.as_str(),
Self::Plugin(plugin) => plugin.id.as_str(),
}
}
pub(crate) fn name(&self) -> &str {
match self {
Self::Connector(connector) => connector.name.as_str(),
Self::Plugin(plugin) => plugin.name.as_str(),
}
}
pub(crate) fn description(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.description.as_deref(),
Self::Plugin(plugin) => plugin.description.as_deref(),
}
}
pub(crate) fn install_url(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.install_url.as_deref(),
Self::Plugin(_) => None,
}
}
}
impl From<AppInfo> for DiscoverableTool {
fn from(value: AppInfo) -> Self {
Self::Connector(Box::new(value))
}
}
impl From<DiscoverablePluginInfo> for DiscoverableTool {
fn from(value: DiscoverablePluginInfo) -> Self {
Self::Plugin(Box::new(value))
}
}
pub(crate) fn filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools: Vec<DiscoverableTool>,
app_server_client_name: Option<&str>,
) -> Vec<DiscoverableTool> {
if app_server_client_name != Some(TUI_APP_SERVER_CLIENT_NAME) {
return discoverable_tools;
}
discoverable_tools
.into_iter()
.filter(|tool| !matches!(tool, DiscoverableTool::Plugin(_)))
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DiscoverablePluginInfo {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) has_skills: bool,
pub(crate) mcp_server_names: Vec<String>,
pub(crate) app_connector_ids: Vec<String>,
}
impl From<PluginCapabilitySummary> for DiscoverablePluginInfo {
fn from(value: PluginCapabilitySummary) -> Self {
Self {
id: value.config_name,
name: value.display_name,
description: value.description,
has_skills: value.has_skills,
mcp_server_names: value.mcp_server_names,
app_connector_ids: value
.app_connector_ids
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),
}
}
}

Some files were not shown because too many files have changed in this diff Show More