Merge dev/winston/mitm-ca-trust into dev/winston/credentialed-routes-discovery

This commit is contained in:
Winston Howes
2026-05-26 11:13:42 -07:00
1149 changed files with 71721 additions and 32523 deletions
+85 -43
View File
@@ -96,7 +96,7 @@ fn agent_nickname_candidates(
.collect()
}
fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool {
match item {
RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str()
{
@@ -120,13 +120,30 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other,
) => false,
// A forked child gets its own runtime config, including spawned-agent
// instructions, so it must establish a fresh context diff baseline.
RolloutItem::TurnContext(_) => false,
// Full-history forks preserve the cached prompt prefix and can keep diffing
// from the parent's durable baseline. Truncated forks drop part of that prompt,
// so they must rebuild context on their first child turn.
RolloutItem::TurnContext(_) => preserve_reference_context_item,
RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true,
}
}
fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "developer" {
return false;
}
let [ContentItem::InputText { text }] = content.as_slice() else {
return false;
};
usage_hint_texts
.iter()
.any(|usage_hint_text| usage_hint_text == text)
}
/// Control-plane handle for multi-agent operations.
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
/// spawn new agents and the inter-agent communication layer.
@@ -396,16 +413,26 @@ impl AgentControl {
forked_rollout_items =
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
}
// MultiAgentV2 root/subagent usage hints are injected as standalone developer
// messages at thread start. When forking history, drop hints from the parent
// so the child gets a fresh hint that matches its own session source/config.
let multi_agent_v2_usage_hint_texts_to_filter: Vec<String> =
if let Some(parent_thread) = parent_thread.as_ref() {
parent_thread
.codex
.session
.configured_multi_agent_v2_usage_hint_texts()
.await
if parent_thread.enabled(Feature::MultiAgentV2) {
let parent_config = parent_thread.codex.session.get_config().await;
[
parent_config
.multi_agent_v2
.root_agent_usage_hint_text
.clone(),
parent_config
.multi_agent_v2
.subagent_usage_hint_text
.clone(),
]
.into_iter()
.flatten()
.collect()
} else {
Vec::new()
}
} else if config.features.enabled(Feature::MultiAgentV2) {
[
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
@@ -417,19 +444,41 @@ impl AgentControl {
} else {
Vec::new()
};
let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory);
forked_rollout_items.retain(|item| {
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item
&& role == "developer"
&& let [ContentItem::InputText { text }] = content.as_slice()
&& multi_agent_v2_usage_hint_texts_to_filter
.iter()
.any(|usage_hint_text| usage_hint_text == text)
{
return false;
}
keep_forked_rollout_item(item)
keep_forked_rollout_item(item, preserve_reference_context_item)
&& !matches!(
item,
RolloutItem::ResponseItem(response_item)
if is_multi_agent_v2_usage_hint_message(
response_item,
&multi_agent_v2_usage_hint_texts_to_filter,
)
)
});
for item in &mut forked_rollout_items {
if let RolloutItem::Compacted(compacted) = item
&& let Some(replacement_history) = compacted.replacement_history.as_mut()
{
replacement_history.retain(|response_item| {
!is_multi_agent_v2_usage_hint_message(
response_item,
&multi_agent_v2_usage_hint_texts_to_filter,
)
});
}
}
if preserve_reference_context_item
&& config.features.enabled(Feature::MultiAgentV2)
&& let Some(subagent_usage_hint_text) =
config.multi_agent_v2.subagent_usage_hint_text.clone()
&& let Some(subagent_usage_hint_message) =
crate::context_manager::updates::build_developer_update_item(vec![
subagent_usage_hint_text,
])
{
forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message));
}
state
.fork_thread_with_source(
@@ -499,13 +548,12 @@ impl AgentControl {
agent_nickname: None,
agent_role: None,
});
match self
.resume_single_agent_from_rollout(
config.clone(),
child_thread_id,
child_session_source,
)
.await
match Box::pin(self.resume_single_agent_from_rollout(
config.clone(),
child_thread_id,
child_session_source,
))
.await
{
Ok(_) => true,
Err(err) => {
@@ -1117,24 +1165,16 @@ impl AgentControl {
let state = self.upgrade()?;
let mut children_by_parent = HashMap::<ThreadId, Vec<(ThreadId, AgentMetadata)>>::new();
for thread_id in state.list_thread_ids().await {
let Ok(thread) = state.get_thread(thread_id).await else {
continue;
};
let snapshot = thread.config_snapshot().await;
let Some(parent_thread_id) = thread_spawn_parent_thread_id(&snapshot.session_source)
else {
continue;
};
for (parent_thread_id, child_thread_id) in state.list_live_thread_spawn_edges().await {
children_by_parent
.entry(parent_thread_id)
.or_default()
.push((
thread_id,
child_thread_id,
self.state
.agent_metadata_for_thread(thread_id)
.agent_metadata_for_thread(child_thread_id)
.unwrap_or(AgentMetadata {
agent_id: Some(thread_id),
agent_id: Some(child_thread_id),
..Default::default()
}),
));
@@ -1235,7 +1275,9 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
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(),
+329 -2
View File
@@ -17,6 +17,7 @@ use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
@@ -270,6 +271,7 @@ async fn get_status_returns_not_found_without_manager() {
async fn on_event_updates_status_from_task_started() {
let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
@@ -442,6 +444,7 @@ async fn send_input_submits_user_message() {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
thread_settings: Default::default(),
},
);
let captured = harness
@@ -486,7 +489,13 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
timeout(Duration::from_secs(5), async {
loop {
if thread.codex.session.has_pending_input().await {
if thread
.codex
.session
.input_queue
.has_pending_input(&thread.codex.session.active_turn)
.await
{
break;
}
sleep(Duration::from_millis(10)).await;
@@ -589,6 +598,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
thread_settings: Default::default(),
},
);
let captured = harness
@@ -669,6 +679,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
],
)
.await;
let parent_reference_context_item = turn_context.to_turn_context_item();
parent_thread
.codex
.session
.persist_rollout_items(&[RolloutItem::TurnContext(
parent_reference_context_item.clone(),
)])
.await;
parent_thread
.codex
.session
@@ -720,11 +738,26 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
phase: None,
},
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Child subagent guidance.".to_string(),
}],
phase: None,
},
];
assert_eq!(
history.raw_items(),
&expected_history,
"forked child history should keep only parent user messages and assistant final answers"
"full-history forked child history should replace parent usage hints with the child subagent hint while filtering non-final assistant/tool chatter"
);
assert_eq!(
serde_json::to_value(child_thread.codex.session.reference_context_item().await)
.expect("serialize child reference context item"),
serde_json::to_value(Some(parent_reference_context_item))
.expect("serialize expected reference context item"),
"full-history forked child should preserve the parent diff baseline"
);
let expected = (
@@ -737,6 +770,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
thread_settings: Default::default(),
},
);
let captured = harness
@@ -757,6 +791,124 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
parent_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Parent root guidance.".to_string());
parent_config.multi_agent_v2.subagent_usage_hint_text =
Some("Parent subagent guidance.".to_string());
let mut child_config = harness.config.clone();
let _ = child_config.features.enable(Feature::MultiAgentV2);
child_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Child root guidance.".to_string());
child_config.multi_agent_v2.subagent_usage_hint_text =
Some("Child subagent guidance.".to_string());
let new_thread = harness
.manager
.start_thread(parent_config)
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
let turn_context = parent_thread.codex.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string();
let replacement_history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "compacted parent summary".to_string(),
}],
phase: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent root guidance.".to_string(),
}],
phase: None,
},
];
parent_thread
.codex
.session
.persist_rollout_items(&[
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(replacement_history),
}),
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)),
])
.await;
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread
.codex
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
child_config,
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::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should sanitize compacted usage hints")
.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(), "compacted parent summary"),
"forked child history should retain compacted non-hint content"
);
assert!(
!history_contains_text(history.raw_items(), "Parent root guidance."),
"forked child history should strip stale parent hints from compacted replacement history"
);
assert!(
history_contains_text(history.raw_items(), "Child subagent guidance."),
"full-history forked child should add the child subagent hint after compacted-history sanitization"
);
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_fork_flushes_parent_rollout_before_loading_history() {
let harness = AgentControlHarness::new().await;
@@ -873,6 +1025,13 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
&[spawn_agent_call(&parent_spawn_call_id)],
)
.await;
parent_thread
.codex
.session
.persist_rollout_items(&[RolloutItem::TurnContext(
spawn_turn_context.to_turn_context_item(),
)])
.await;
parent_thread
.codex
.session
@@ -930,6 +1089,116 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
history_contains_text(history.raw_items(), "current parent task"),
"forked child history should keep the parent user message from the requested last-N turn window"
);
assert!(
child_thread
.codex
.session
.reference_context_item()
.await
.is_none(),
"last-N forked child should rebuild context after truncating the cached prefix"
);
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_fork_last_n_turns_strips_parent_usage_hints() {
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
parent_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Parent root guidance.".to_string());
let mut child_config = harness.config.clone();
let _ = child_config.features.enable(Feature::MultiAgentV2);
child_config.multi_agent_v2.subagent_usage_hint_text =
Some("Child subagent guidance.".to_string());
let new_thread = harness
.manager
.start_thread(parent_config)
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
parent_thread
.inject_user_message_without_turn("parent task".to_string())
.await;
let turn_context = parent_thread.codex.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-last-n-usage-hints".to_string();
parent_thread
.codex
.session
.record_conversation_items(
turn_context.as_ref(),
&[
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent root guidance.".to_string(),
}],
phase: None,
},
spawn_agent_call(&parent_spawn_call_id),
],
)
.await;
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread
.codex
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
child_config,
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)),
..Default::default()
},
)
.await
.expect("bounded forked spawn should sanitize parent usage hints")
.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(), "parent task"),
"bounded fork should retain the requested recent parent turn"
);
assert!(
!history_contains_text(history.raw_items(), "Parent root guidance."),
"bounded fork should strip stale parent root hints before the child rebuilds startup context"
);
let _ = harness
.control
@@ -1855,6 +2124,64 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants
);
}
#[tokio::test]
async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() {
let (_home, config) = test_config().await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*state_db*/ None,
);
let control = manager.agent_control();
let parent_thread_id = manager
.start_thread(config.clone())
.await
.expect("parent should start")
.thread_id;
let child_thread_id = control
.spawn_agent(
config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = control
.spawn_agent(
config,
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let mut subtree_thread_ids = manager
.list_agent_subtree_thread_ids(parent_thread_id)
.await
.expect("live subtree should load");
subtree_thread_ids.sort_by_key(ToString::to_string);
let mut expected_subtree_thread_ids =
vec![parent_thread_id, child_thread_id, grandchild_thread_id];
expected_subtree_thread_ids.sort_by_key(ToString::to_string);
assert_eq!(subtree_thread_ids, expected_subtree_thread_ids);
}
#[tokio::test]
async fn shutdown_agent_tree_closes_live_descendants() {
let harness = AgentControlHarness::new().await;
-161
View File
@@ -1,161 +0,0 @@
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,14 +1,11 @@
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;
+38 -85
View File
@@ -2,9 +2,9 @@
//!
//! Roles are selected at spawn time and are loaded with the same config machinery as
//! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a
//! high-precedence layer, and preserves the caller's current profile/provider unless the role
//! explicitly takes ownership of model selection. It does not decide when to spawn a sub-agent or
//! which role to use; the multi-agent tool handler owns that orchestration.
//! high-precedence layer, and preserves the caller's current provider and service tier unless the
//! role layer sets them. It does not decide when to spawn a sub-agent or which role to use; the
//! multi-agent tool handler owns that orchestration.
use crate::config::AgentRoleConfig;
use crate::config::Config;
@@ -29,14 +29,12 @@ use toml::Value as TomlValue;
pub const DEFAULT_ROLE_NAME: &str = "default";
const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available";
/// Applies a named role layer to `config` while preserving caller-owned model selection.
/// Applies a named role layer to `config` while preserving caller-owned provider settings.
///
/// The role layer is inserted at session-flag precedence so it can override persisted config, but
/// the caller's current `profile` and `model_provider` remain sticky runtime choices unless the
/// role explicitly sets `profile`, explicitly sets `model_provider`, or rewrites the active
/// profile's `model_provider` in place. Rebuilding the config without those overrides would make a
/// spawned agent silently fall back to the default provider, which is the bug this preservation
/// logic avoids.
/// the caller's current `model_provider` and `service_tier` remain sticky runtime choices unless
/// the role explicitly sets the corresponding top-level config key. Rebuilding the config without
/// those overrides would make a spawned agent silently fall back to default settings.
pub(crate) async fn apply_role_to_config(
config: &mut Config,
role_name: Option<&str>,
@@ -71,14 +69,14 @@ async fn apply_role_to_config_inner(
{
return Ok(());
}
let (preserve_current_profile, preserve_current_provider) =
preservation_policy(config, &role_layer_toml);
let preserve_current_provider = role_layer_toml.get("model_provider").is_none();
let preserve_current_service_tier = role_layer_toml.get("service_tier").is_none();
*config = reload::build_next_config(
config,
role_layer_toml,
preserve_current_profile,
preserve_current_provider,
preserve_current_service_tier,
)
.await?;
Ok(())
@@ -128,71 +126,38 @@ pub(crate) fn resolve_role_config<'a>(
.or_else(|| built_in::configs().get(role_name))
}
fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) {
let role_selects_provider = role_layer_toml.get("model_provider").is_some();
let role_selects_profile = role_layer_toml.get("profile").is_some();
let role_updates_active_profile_provider = config
.active_profile
.as_ref()
.and_then(|active_profile| {
role_layer_toml
.get("profiles")
.and_then(TomlValue::as_table)
.and_then(|profiles| profiles.get(active_profile))
.and_then(TomlValue::as_table)
.map(|profile| profile.contains_key("model_provider"))
})
.unwrap_or(false);
let preserve_current_profile = !role_selects_provider && !role_selects_profile;
let preserve_current_provider =
preserve_current_profile && !role_updates_active_profile_provider;
(preserve_current_profile, preserve_current_provider)
}
mod reload {
use super::*;
pub(super) async fn build_next_config(
config: &Config,
role_layer_toml: TomlValue,
preserve_current_profile: bool,
preserve_current_provider: bool,
preserve_current_service_tier: bool,
) -> anyhow::Result<Config> {
let active_profile_name = preserve_current_profile
.then_some(config.active_profile.as_deref())
.flatten();
let config_layer_stack =
build_config_layer_stack(config, &role_layer_toml, active_profile_name)?;
let mut merged_config = deserialize_effective_config(config, &config_layer_stack)?;
if preserve_current_profile {
merged_config.profile = None;
}
let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?;
let merged_config = deserialize_effective_config(config, &config_layer_stack)?;
let mut next_config = Config::load_config_with_layer_stack(
let next_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
merged_config,
reload_overrides(config, preserve_current_provider),
reload_overrides(
config,
preserve_current_provider,
preserve_current_service_tier,
),
config.codex_home.clone(),
config_layer_stack,
)
.await?;
if preserve_current_profile {
next_config.active_profile = config.active_profile.clone();
}
Ok(next_config)
}
fn build_config_layer_stack(
config: &Config,
role_layer_toml: &TomlValue,
active_profile_name: Option<&str>,
) -> anyhow::Result<ConfigLayerStack> {
let mut layers = existing_layers(config);
if let Some(resolved_profile_layer) =
resolved_profile_layer(config, &layers, role_layer_toml, active_profile_name)?
{
insert_layer(&mut layers, resolved_profile_layer);
}
insert_layer(&mut layers, role_layer(role_layer_toml.clone()));
Ok(ConfigLayerStack::new(
layers,
@@ -201,34 +166,6 @@ mod reload {
)?)
}
fn resolved_profile_layer(
config: &Config,
existing_layers: &[ConfigLayerEntry],
role_layer_toml: &TomlValue,
active_profile_name: Option<&str>,
) -> anyhow::Result<Option<ConfigLayerEntry>> {
let Some(active_profile_name) = active_profile_name else {
return Ok(None);
};
let mut layers = existing_layers.to_vec();
insert_layer(&mut layers, role_layer(role_layer_toml.clone()));
let merged_config = deserialize_effective_config(
config,
&ConfigLayerStack::new(
layers,
config.config_layer_stack.requirements().clone(),
config.config_layer_stack.requirements_toml().clone(),
)?,
)?;
let resolved_profile =
merged_config.get_config_profile(Some(active_profile_name.to_string()))?;
Ok(Some(ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
TomlValue::try_from(resolved_profile)?,
)))
}
fn deserialize_effective_config(
config: &Config,
config_layer_stack: &ConfigLayerStack,
@@ -261,10 +198,15 @@ mod reload {
ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml)
}
fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides {
fn reload_overrides(
config: &Config,
preserve_current_provider: bool,
preserve_current_service_tier: bool,
) -> ConfigOverrides {
ConfigOverrides {
cwd: Some(config.cwd.to_path_buf()),
model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()),
service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
..Default::default()
@@ -323,8 +265,11 @@ pub(crate) mod spawn_tool_spec {
let reasoning_effort = role_toml
.get("model_reasoning_effort")
.and_then(TomlValue::as_str);
let service_tier = role_toml
.get("service_tier")
.and_then(TomlValue::as_str);
match (model, reasoning_effort) {
let model_and_reasoning_note = match (model, reasoning_effort) {
(Some(model), Some(reasoning_effort)) => format!(
"\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed."
),
@@ -339,7 +284,15 @@ pub(crate) mod spawn_tool_spec {
)
}
(None, None) => String::new(),
}
};
let service_tier_note = service_tier
.map(|service_tier| {
format!(
"\n- This role's service tier is set to `{service_tier}`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier."
)
})
.unwrap_or_default();
format!("{model_and_reasoning_note}{service_tier_note}")
})
.unwrap_or_default();
format!("{name}: {{\n{description}{locked_settings_note}\n}}")
+40 -252
View File
@@ -1,12 +1,10 @@
use super::*;
use crate::SkillsManager;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::skills_load_input_from_config;
use codex_config::ConfigLayerStackOrdering;
use codex_core_plugins::PluginsManager;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
@@ -215,90 +213,13 @@ async fn apply_role_preserves_unspecified_keys() {
}
#[tokio::test]
async fn apply_role_preserves_active_profile_and_model_provider() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.test-provider]
name = "Test Provider"
base_url = "https://example.com/v1"
env_key = "TEST_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.test-profile]
model_provider = "test-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("test-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
async fn apply_role_reports_explicit_service_tier() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let role_path = write_role_config(
&home,
"empty-role.toml",
"developer_instructions = \"Stay focused\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("test-profile"));
assert_eq!(config.model_provider_id, "test-provider");
assert_eq!(config.model_provider.name, "Test Provider");
}
#[tokio::test]
async fn apply_role_top_level_profile_settings_override_preserved_profile() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[profiles.base-profile]
model = "profile-model"
model_reasoning_effort = "low"
model_reasoning_summary = "concise"
model_verbosity = "low"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"top-level-profile-settings-role.toml",
"tiered-role.toml",
r#"developer_instructions = "Stay focused"
model = "role-model"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
model_verbosity = "high"
service_tier = "priority"
"#,
)
.await;
@@ -315,178 +236,20 @@ model_verbosity = "high"
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("base-profile"));
assert_eq!(config.model.as_deref(), Some("role-model"));
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
config.model_reasoning_summary,
Some(ReasoningSummary::Detailed)
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(config.model_verbosity, Some(Verbosity::High));
}
#[tokio::test]
async fn apply_role_uses_role_profile_instead_of_current_profile() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
[profiles.role-profile]
model_provider = "role-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
async fn apply_role_preserves_existing_service_tier_without_override() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
let role_path = write_role_config(
&home,
"profile-role.toml",
"developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("role-profile"));
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
}
#[tokio::test]
async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"provider-role.toml",
"developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile, None);
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
}
#[tokio::test]
async fn apply_role_uses_active_profile_model_provider_update() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
model_reasoning_effort = "low"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"profile-edit-role.toml",
"default-tier-role.toml",
r#"developer_instructions = "Stay focused"
[profiles.base-profile]
model_provider = "role-provider"
model_reasoning_effort = "high"
"#,
)
.await;
@@ -503,10 +266,10 @@ model_reasoning_effort = "high"
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("base-profile"));
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[tokio::test]
@@ -766,6 +529,31 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
));
}
#[test]
fn spawn_tool_spec_marks_role_locked_service_tier() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("tiered.toml");
fs::write(
&role_path,
"developer_instructions = \"Stay fast\"\nservice_tier = \"priority\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"tiered".to_string(),
AgentRoleConfig {
description: Some("Stay fast.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains(
"Stay fast.\n- This role's service tier is set to `priority`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier."
));
}
#[test]
fn built_in_config_file_contents_resolves_explorer_only() {
assert_eq!(
+59 -15
View File
@@ -23,6 +23,7 @@ use codex_config::merge_toml_values;
use codex_config::project_root_markers_from_config;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize as normalize_path;
@@ -58,20 +59,34 @@ impl<'a> AgentsMdManager<'a> {
Self { config }
}
pub(crate) fn load_global_instructions(
pub(crate) async fn load_global_instructions(
fs: &dyn ExecutorFileSystem,
codex_dir: Option<&AbsolutePathBuf>,
startup_warnings: &mut Vec<String>,
) -> Option<LoadedAgentsMd> {
let base = codex_dir?;
for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] {
let path = base.join(candidate);
if let Ok(contents) = std::fs::read_to_string(&path) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(LoadedAgentsMd {
contents: trimmed.to_string(),
path,
});
let data = match fs.read_file(&path, /*sandbox*/ None).await {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue,
Err(err) => {
startup_warnings.push(format!(
"Failed to read global AGENTS.md instructions from `{}`: {err}",
path.display()
));
continue;
}
};
warn_invalid_utf8(&path, &data, "Global", startup_warnings);
let contents = String::from_utf8_lossy(&data);
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(LoadedAgentsMd {
contents: trimmed.to_string(),
path,
});
}
}
None
@@ -82,16 +97,19 @@ impl<'a> AgentsMdManager<'a> {
pub(crate) async fn user_instructions(
&self,
environment: Option<&Environment>,
startup_warnings: &mut Vec<String>,
) -> Option<String> {
let fs = environment?.get_filesystem();
self.user_instructions_with_fs(fs.as_ref()).await
self.user_instructions_with_fs(fs.as_ref(), startup_warnings)
.await
}
pub(crate) async fn user_instructions_with_fs(
async fn user_instructions_with_fs(
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> Option<String> {
let agents_md_docs = self.read_agents_md(fs).await;
let agents_md_docs = self.read_agents_md(fs, startup_warnings).await;
let mut output = String::new();
@@ -128,9 +146,15 @@ impl<'a> AgentsMdManager<'a> {
/// Returns all instruction source files included in the current config.
pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec<AbsolutePathBuf> {
let mut paths = Self::load_global_instructions(Some(&self.config.codex_home))
.map(|loaded| vec![loaded.path])
.unwrap_or_default();
let mut global_instruction_warnings = Vec::new();
let mut paths = Self::load_global_instructions(
LOCAL_FS.as_ref(),
Some(&self.config.codex_home),
&mut global_instruction_warnings,
)
.await
.map(|loaded| vec![loaded.path])
.unwrap_or_default();
match self.agents_md_paths(fs).await {
Ok(agents_md_paths) => paths.extend(agents_md_paths),
Err(err) => {
@@ -146,7 +170,11 @@ impl<'a> AgentsMdManager<'a> {
/// concatenation of all discovered docs. If no documentation file is found
/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as
/// `Err` so callers can decide how to handle them.
async fn read_agents_md(&self, fs: &dyn ExecutorFileSystem) -> io::Result<Option<String>> {
async fn read_agents_md(
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> io::Result<Option<String>> {
let max_total = self.config.project_doc_max_bytes;
if max_total == 0 {
@@ -178,6 +206,8 @@ impl<'a> AgentsMdManager<'a> {
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
};
warn_invalid_utf8(&p, &data, "Project", startup_warnings);
let size = data.len() as u64;
if size > remaining {
data.truncate(remaining as usize);
@@ -320,6 +350,20 @@ impl<'a> AgentsMdManager<'a> {
}
}
fn warn_invalid_utf8(
path: &AbsolutePathBuf,
data: &[u8],
source: &str,
startup_warnings: &mut Vec<String>,
) {
if let Err(err) = std::str::from_utf8(data) {
startup_warnings.push(format!(
"{source} AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.",
path.display()
));
}
}
#[cfg(test)]
#[path = "agents_md_tests.rs"]
mod tests;
+57 -2
View File
@@ -7,12 +7,14 @@ use core_test_support::PathBufExt;
use core_test_support::TempDirExt;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
async fn get_user_instructions(config: &Config) -> Option<String> {
let mut warnings = Vec::new();
AgentsMdManager::new(config)
.user_instructions_with_fs(LOCAL_FS.as_ref())
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
}
@@ -22,6 +24,19 @@ async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf
.await
}
fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) {
let path_display = path.display().to_string();
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let warning = &warnings[0];
assert!(
warning.contains(&format!("{source} AGENTS.md instructions"))
&& warning.contains(&path_display)
&& warning.contains("invalid UTF-8")
&& warning.contains("Invalid byte sequences were replaced."),
"unexpected invalid UTF-8 warning: {warning:?}"
);
}
/// Helper that returns a `Config` pointing at `root` and using `limit` as
/// the maximum number of bytes to embed from AGENTS.md. The caller can
/// optionally specify a custom `instructions` string – when `None` the
@@ -105,8 +120,9 @@ async fn no_environment_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
let mut warnings = Vec::new();
let res = AgentsMdManager::new(&config)
.user_instructions(/*environment*/ None)
.user_instructions(/*environment*/ None, &mut warnings)
.await;
assert_eq!(res, None);
@@ -129,6 +145,45 @@ async fn doc_smaller_than_limit_is_returned() {
);
}
#[tokio::test]
async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
let codex_home = tempfile::tempdir().expect("tempdir");
let codex_home_abs = codex_home.abs();
let path = codex_home_abs.join(DEFAULT_AGENTS_MD_FILENAME);
fs::write(&path, b"global\xFF doc").unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::load_global_instructions(
LOCAL_FS.as_ref(),
Some(&codex_home_abs),
&mut warnings,
)
.await
.expect("global doc expected");
assert_eq!(loaded.contents, "global\u{FFFD} doc");
assert_eq!(loaded.path, path);
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
}
#[tokio::test]
async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
fs::write(&path, b"project\xFF doc").unwrap();
let config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let mut warnings = Vec::new();
let res = AgentsMdManager::new(&config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("doc expected");
assert_eq!(res, "project\u{FFFD} doc");
let canonical_path = dunce::canonicalize(&path).expect("canonical doc path");
assert_invalid_utf8_warning(&warnings, "Project", &canonical_path);
}
/// Oversize file is truncated to `project_doc_max_bytes`.
#[tokio::test]
async fn doc_larger_than_limit_is_truncated() {
-416
View File
@@ -1,416 +0,0 @@
use std::env;
use std::time::Duration;
use serde::Deserialize;
use serde::Serialize;
use tracing::warn;
use crate::compact::content_items_to_text;
use crate::event_mapping::is_contextual_user_message_content;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use codex_login::default_client::build_reqwest_client;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
const ARC_MONITOR_TIMEOUT: Duration = Duration::from_secs(30);
const CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE: &str = "CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE";
const CODEX_ARC_MONITOR_TOKEN: &str = "CODEX_ARC_MONITOR_TOKEN";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ArcMonitorOutcome {
Ok,
SteerModel(String),
AskUser(String),
}
#[derive(Debug, Serialize, PartialEq)]
struct ArcMonitorRequest {
metadata: ArcMonitorMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
messages: Option<Vec<ArcMonitorChatMessage>>,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<Vec<ResponseItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
policies: Option<ArcMonitorPolicies>,
action: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ArcMonitorResult {
outcome: ArcMonitorResultOutcome,
short_reason: String,
rationale: String,
risk_score: u8,
risk_level: ArcMonitorRiskLevel,
evidence: Vec<ArcMonitorEvidence>,
}
#[derive(Debug, Serialize, PartialEq)]
struct ArcMonitorChatMessage {
role: String,
content: serde_json::Value,
}
#[derive(Debug, Serialize, PartialEq)]
struct ArcMonitorPolicies {
user: Option<String>,
developer: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct ArcMonitorMetadata {
codex_thread_id: String,
codex_turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
protection_client_callsite: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)]
struct ArcMonitorEvidence {
message: String,
why: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum ArcMonitorResultOutcome {
Ok,
SteerModel,
AskUser,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ArcMonitorRiskLevel {
Low,
Medium,
High,
Critical,
}
pub(crate) async fn monitor_action(
sess: &Session,
turn_context: &TurnContext,
action: serde_json::Value,
protection_client_callsite: &'static str,
) -> ArcMonitorOutcome {
let auth = match turn_context.auth_manager.as_ref() {
Some(auth_manager) => match auth_manager.auth().await {
Some(auth) if auth.uses_codex_backend() => Some(auth),
_ => None,
},
None => None,
};
let env_token = read_non_empty_env_var(CODEX_ARC_MONITOR_TOKEN);
if env_token.is_none() && auth.is_none() {
return ArcMonitorOutcome::Ok;
}
let url = read_non_empty_env_var(CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE).unwrap_or_else(|| {
format!(
"{}/codex/safety/arc",
turn_context.config.chatgpt_base_url.trim_end_matches('/')
)
});
let action = match action {
serde_json::Value::Object(action) => action,
_ => {
warn!("skipping safety monitor because action payload is not an object");
return ArcMonitorOutcome::Ok;
}
};
let body =
build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await;
let client = build_reqwest_client();
let mut request = client.post(&url).timeout(ARC_MONITOR_TIMEOUT).json(&body);
if let Some(token) = env_token {
request = request.bearer_auth(token);
} else if let Some(auth) = auth.as_ref() {
request =
request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());
}
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
warn!(error = %err, %url, "safety monitor request failed");
return ArcMonitorOutcome::Ok;
}
};
let status = response.status();
if !status.is_success() {
let response_text = response.text().await.unwrap_or_default();
warn!(
%status,
%url,
response_text,
"safety monitor returned non-success status"
);
return ArcMonitorOutcome::Ok;
}
let response = match response.json::<ArcMonitorResult>().await {
Ok(response) => response,
Err(err) => {
warn!(error = %err, %url, "failed to parse safety monitor response");
return ArcMonitorOutcome::Ok;
}
};
tracing::debug!(
risk_score = response.risk_score,
risk_level = ?response.risk_level,
evidence_count = response.evidence.len(),
"safety monitor completed"
);
let short_reason = response.short_reason.trim();
let rationale = response.rationale.trim();
match response.outcome {
ArcMonitorResultOutcome::Ok => ArcMonitorOutcome::Ok,
ArcMonitorResultOutcome::AskUser => {
if !short_reason.is_empty() {
ArcMonitorOutcome::AskUser(short_reason.to_string())
} else if !rationale.is_empty() {
ArcMonitorOutcome::AskUser(rationale.to_string())
} else {
ArcMonitorOutcome::AskUser(
"Additional confirmation is required before this tool call can continue."
.to_string(),
)
}
}
ArcMonitorResultOutcome::SteerModel => {
if !rationale.is_empty() {
ArcMonitorOutcome::SteerModel(rationale.to_string())
} else if !short_reason.is_empty() {
ArcMonitorOutcome::SteerModel(short_reason.to_string())
} else {
ArcMonitorOutcome::SteerModel(
"Tool call was cancelled because of safety risks.".to_string(),
)
}
}
}
}
fn read_non_empty_env_var(key: &str) -> Option<String> {
match env::var(key) {
Ok(value) => {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
Err(env::VarError::NotPresent) => None,
Err(env::VarError::NotUnicode(_)) => {
warn!(
env_var = key,
"ignoring non-unicode safety monitor env override"
);
None
}
}
}
async fn build_arc_monitor_request(
sess: &Session,
turn_context: &TurnContext,
action: serde_json::Map<String, serde_json::Value>,
protection_client_callsite: &'static str,
) -> ArcMonitorRequest {
let history = sess.clone_history().await;
let mut messages = build_arc_monitor_messages(history.raw_items());
if messages.is_empty() {
messages.push(build_arc_monitor_message(
"user",
serde_json::Value::String(
"No prior conversation history is available for this ARC evaluation.".to_string(),
),
));
}
let conversation_id = sess.conversation_id.to_string();
ArcMonitorRequest {
metadata: ArcMonitorMetadata {
codex_thread_id: conversation_id.clone(),
codex_turn_id: turn_context.sub_id.clone(),
conversation_id: Some(conversation_id),
protection_client_callsite: Some(protection_client_callsite.to_string()),
},
messages: Some(messages),
input: None,
policies: Some(ArcMonitorPolicies {
user: None,
developer: None,
}),
action,
}
}
fn build_arc_monitor_messages(items: &[ResponseItem]) -> Vec<ArcMonitorChatMessage> {
let last_tool_call_index = items
.iter()
.enumerate()
.rev()
.find(|(_, item)| {
matches!(
item,
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::WebSearchCall { .. }
)
})
.map(|(index, _)| index);
let last_encrypted_reasoning_index = items
.iter()
.enumerate()
.rev()
.find(|(_, item)| {
matches!(
item,
ResponseItem::Reasoning {
encrypted_content: Some(encrypted_content),
..
} if !encrypted_content.trim().is_empty()
)
})
.map(|(index, _)| index);
items
.iter()
.enumerate()
.filter_map(|(index, item)| {
build_arc_monitor_message_item(
item,
index,
last_tool_call_index,
last_encrypted_reasoning_index,
)
})
.collect()
}
fn build_arc_monitor_message_item(
item: &ResponseItem,
index: usize,
last_tool_call_index: Option<usize>,
last_encrypted_reasoning_index: Option<usize>,
) -> Option<ArcMonitorChatMessage> {
match item {
ResponseItem::Message { role, content, .. } if role == "user" => {
if is_contextual_user_message_content(content) {
None
} else {
content_items_to_text(content)
.map(|text| build_arc_monitor_text_message("user", "input_text", text))
}
}
ResponseItem::Message {
role,
content,
phase: Some(MessagePhase::FinalAnswer),
..
} if role == "assistant" => content_items_to_text(content)
.map(|text| build_arc_monitor_text_message("assistant", "output_text", text)),
ResponseItem::Message { .. } => None,
ResponseItem::Reasoning {
encrypted_content: Some(encrypted_content),
..
} if Some(index) == last_encrypted_reasoning_index
&& !encrypted_content.trim().is_empty() =>
{
Some(build_arc_monitor_message(
"assistant",
serde_json::json!([{
"type": "encrypted_reasoning",
"encrypted_content": encrypted_content,
}]),
))
}
ResponseItem::Reasoning { .. } => None,
ResponseItem::LocalShellCall { action, .. } if Some(index) == last_tool_call_index => {
Some(build_arc_monitor_message(
"assistant",
serde_json::json!([{
"type": "tool_call",
"tool_name": "shell",
"action": action,
}]),
))
}
ResponseItem::FunctionCall {
name, arguments, ..
} if Some(index) == last_tool_call_index => Some(build_arc_monitor_message(
"assistant",
serde_json::json!([{
"type": "tool_call",
"tool_name": name,
"arguments": arguments,
}]),
)),
ResponseItem::CustomToolCall { name, input, .. } if Some(index) == last_tool_call_index => {
Some(build_arc_monitor_message(
"assistant",
serde_json::json!([{
"type": "tool_call",
"tool_name": name,
"input": input,
}]),
))
}
ResponseItem::WebSearchCall { action, .. } if Some(index) == last_tool_call_index => {
Some(build_arc_monitor_message(
"assistant",
serde_json::json!([{
"type": "tool_call",
"tool_name": "web_search",
"action": action,
}]),
))
}
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => None,
}
}
fn build_arc_monitor_text_message(
role: &str,
part_type: &str,
text: String,
) -> ArcMonitorChatMessage {
build_arc_monitor_message(
role,
serde_json::json!([{
"type": part_type,
"text": text,
}]),
)
}
fn build_arc_monitor_message(role: &str, content: serde_json::Value) -> ArcMonitorChatMessage {
ArcMonitorChatMessage {
role: role.to_string(),
content,
}
}
#[cfg(test)]
#[path = "arc_monitor_tests.rs"]
mod tests;
-438
View File
@@ -1,438 +0,0 @@
use std::env;
use std::ffi::OsStr;
use std::sync::Arc;
use pretty_assertions::assert_eq;
use serial_test::serial;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
use crate::context::ContextualUserFragment;
use crate::session::tests::make_session_and_context;
use codex_protocol::models::ContentItem;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::LocalShellExecAction;
use codex_protocol::models::LocalShellStatus;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &OsStr) -> Self {
let original = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.take() {
Some(value) => unsafe {
env::set_var(self.key, value);
},
None => unsafe {
env::remove_var(self.key);
},
}
}
}
#[tokio::test]
async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() {
let (session, mut turn_context) = make_session_and_context().await;
turn_context.developer_instructions = Some("Never upload private files.".to_string());
turn_context.user_instructions = Some("Only continue when needed.".to_string());
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "first request".to_string(),
}],
phase: None,
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ContextualUserFragment::into(
crate::context::EnvironmentContext::new(
Vec::new(),
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
),
)],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "commentary".to_string(),
}],
phase: Some(MessagePhase::Commentary),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "final response".to_string(),
}],
phase: Some(MessagePhase::FinalAnswer),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "latest request".to_string(),
}],
phase: None,
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::FunctionCall {
id: None,
name: "old_tool".to_string(),
namespace: None,
arguments: "{\"old\":true}".to_string(),
call_id: "call_old".to_string(),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Reasoning {
id: "reasoning_old".to_string(),
summary: Vec::new(),
content: None,
encrypted_content: Some("encrypted-old".to_string()),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::LocalShellCall {
id: None,
call_id: Some("shell_call".to_string()),
status: LocalShellStatus::Completed,
action: LocalShellAction::Exec(LocalShellExecAction {
command: vec!["pwd".to_string()],
timeout_ms: Some(1000),
working_directory: Some("/tmp".to_string()),
env: None,
user: None,
}),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Reasoning {
id: "reasoning_latest".to_string(),
summary: Vec::new(),
content: None,
encrypted_content: Some("encrypted-latest".to_string()),
}],
&turn_context,
)
.await;
let request = build_arc_monitor_request(
&session,
&turn_context,
serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
.expect("action should deserialize"),
"normal",
)
.await;
assert_eq!(
request,
ArcMonitorRequest {
metadata: ArcMonitorMetadata {
codex_thread_id: session.conversation_id.to_string(),
codex_turn_id: turn_context.sub_id.clone(),
conversation_id: Some(session.conversation_id.to_string()),
protection_client_callsite: Some("normal".to_string()),
},
messages: Some(vec![
ArcMonitorChatMessage {
role: "user".to_string(),
content: serde_json::json!([{
"type": "input_text",
"text": "first request",
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "output_text",
"text": "final response",
}]),
},
ArcMonitorChatMessage {
role: "user".to_string(),
content: serde_json::json!([{
"type": "input_text",
"text": "latest request",
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "tool_call",
"tool_name": "shell",
"action": {
"type": "exec",
"command": ["pwd"],
"timeout_ms": 1000,
"working_directory": "/tmp",
"env": null,
"user": null,
},
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "encrypted_reasoning",
"encrypted_content": "encrypted-latest",
}]),
},
]),
input: None,
policies: Some(ArcMonitorPolicies {
user: None,
developer: None,
}),
action: serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
.expect("action should deserialize"),
}
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_posts_expected_arc_request() {
let server = MockServer::start().await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
turn_context.developer_instructions = Some("Developer policy".to_string());
turn_context.user_instructions = Some("User policy".to_string());
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
phase: None,
}],
&turn_context,
)
.await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(serde_json::json!({
"metadata": {
"codex_thread_id": session.conversation_id.to_string(),
"codex_turn_id": turn_context.sub_id.clone(),
"conversation_id": session.conversation_id.to_string(),
"protection_client_callsite": "normal",
},
"messages": [{
"role": "user",
"content": [{
"type": "input_text",
"text": "please run the tool",
}],
}],
"policies": {
"developer": null,
"user": null,
},
"action": {
"tool": "mcp_tool_call",
},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "ask-user",
"short_reason": "needs confirmation",
"rationale": "tool call needs additional review",
"risk_score": 42,
"risk_level": "medium",
"evidence": [{
"message": "browser_navigate",
"why": "tool call needs additional review",
}],
})))
.expect(1)
.mount(&server)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
"normal",
)
.await;
assert_eq!(
outcome,
ArcMonitorOutcome::AskUser("needs confirmation".to_string())
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_uses_env_url_and_token_overrides() {
let server = MockServer::start().await;
let _url_guard = EnvVarGuard::set(
CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE,
OsStr::new(&format!("{}/override/arc", server.uri())),
);
let _token_guard = EnvVarGuard::set(CODEX_ARC_MONITOR_TOKEN, OsStr::new("override-token"));
let (session, turn_context) = make_session_and_context().await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
phase: None,
}],
&turn_context,
)
.await;
Mock::given(method("POST"))
.and(path("/override/arc"))
.and(header("authorization", "Bearer override-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "browser_navigate",
"why": "high-risk action",
}],
})))
.expect(1)
.mount(&server)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
"normal",
)
.await;
assert_eq!(
outcome,
ArcMonitorOutcome::SteerModel("high-risk action".to_string())
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_rejects_legacy_response_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"reason": "legacy high-risk action",
"monitorRequestId": "arc_456",
})))
.expect(1)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
phase: None,
}],
&turn_context,
)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
"normal",
)
.await;
assert_eq!(outcome, ArcMonitorOutcome::Ok);
}
+39 -16
View File
@@ -146,6 +146,9 @@ const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str =
const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
const RESPONSES_ENDPOINT: &str = "/responses";
const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact";
// `/responses/compact` is unary, so the timeout covers the full response rather than one idle
// period between stream events.
const COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER: u32 = 4;
const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize";
#[cfg(test)]
pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
@@ -257,6 +260,7 @@ struct WebsocketSession {
connection: Option<ApiWebSocketConnection>,
last_request: Option<ResponsesApiRequest>,
last_response_rx: Option<oneshot::Receiver<LastResponse>>,
last_response_from_untraced_warmup: bool,
connection_reused: StdMutex<bool>,
}
@@ -502,12 +506,16 @@ impl ModelClient {
if let Some(header_value) = self.generate_attestation_header_for().await {
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
let compact_request_timeout = client_setup
.api_provider
.stream_idle_timeout
.saturating_mul(COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER);
let client =
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.with_telemetry(Some(request_telemetry));
let trace_attempt = compaction_trace.start_attempt(&payload);
let result = client
.compact_input(&payload, extra_headers)
.compact_input(&payload, extra_headers, compact_request_timeout)
.await
.map_err(map_api_error);
trace_attempt.record_result(result.as_deref());
@@ -741,8 +749,7 @@ impl ModelClient {
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.state.thread_id.to_string());
let service_tier =
service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier));
let service_tier = model_info.service_tier_for_request(service_tier);
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
@@ -931,10 +938,11 @@ impl Drop for ModelClientSession {
}
impl ModelClientSession {
pub(crate) fn reset_websocket_session(&mut self) {
fn reset_websocket_session(&mut self) {
self.websocket_session.connection = None;
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
self.websocket_session.last_response_from_untraced_warmup = false;
self.websocket_session
.set_connection_reused(/*connection_reused*/ false);
}
@@ -1038,28 +1046,33 @@ impl ModelClientSession {
&mut self,
payload: ResponseCreateWsRequest,
request: &ResponsesApiRequest,
) -> ResponsesWsRequest {
) -> (ResponsesWsRequest, bool) {
let Some(last_response) = self.get_last_response() else {
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
};
let previous_response_id_from_untraced_warmup =
self.websocket_session.last_response_from_untraced_warmup;
let Some(incremental_items) = self.get_incremental_items(
request,
Some(&last_response),
/*allow_empty_delta*/ true,
) else {
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
};
if last_response.response_id.is_empty() {
trace!("incremental request failed, no previous response id");
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
}
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
previous_response_id: Some(last_response.response_id),
input: incremental_items,
..payload
})
(
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
previous_response_id: Some(last_response.response_id),
input: incremental_items,
..payload
}),
previous_response_id_from_untraced_warmup,
)
}
/// Opportunistically preconnects a websocket for this turn-scoped client session.
@@ -1138,6 +1151,7 @@ impl ModelClientSession {
if needs_new {
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
self.websocket_session.last_response_from_untraced_warmup = false;
let turn_state = options
.turn_state
.clone()
@@ -1406,8 +1420,8 @@ impl ModelClientSession {
Err(err) => return Err(map_api_error(err)),
}
let mut ws_request = self.prepare_websocket_request(ws_payload, &request);
self.websocket_session.last_request = Some(request);
let (mut ws_request, previous_response_id_from_untraced_warmup) =
self.prepare_websocket_request(ws_payload, &request);
let inference_trace_attempt = if warmup {
// Prewarm sends `generate=false`; it is connection setup, not a
// model inference attempt that should appear in rollout traces.
@@ -1416,7 +1430,16 @@ impl ModelClientSession {
inference_trace.start_attempt()
};
stamp_ws_stream_request_start_ms(&mut ws_request);
inference_trace_attempt.record_started(&ws_request);
if previous_response_id_from_untraced_warmup {
// The transport can reuse an untraced warmup response id and omit the
// already-sent input, but rollout replay needs the logical model-visible
// request rather than the compressed websocket delta.
inference_trace_attempt.record_started(&request);
} else {
inference_trace_attempt.record_started(&ws_request);
}
self.websocket_session.last_request = Some(request);
self.websocket_session.last_response_from_untraced_warmup = warmup;
let websocket_connection =
self.websocket_session.connection.as_ref().ok_or_else(|| {
map_api_error(ApiError::Stream(
+1 -110
View File
@@ -2,13 +2,10 @@ pub use codex_api::ResponseEvent;
use codex_config::types::Personality;
use codex_protocol::error::Result;
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;
use std::collections::HashSet;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
@@ -64,116 +61,10 @@ impl Default for Prompt {
impl Prompt {
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
let mut input = self.input.clone();
// when using the *Freeform* apply_patch tool specifically, tool outputs
// should be structured text, not json. Do NOT reserialize when using
// the Function tool - note that this differs from the check above for
// instructions. We declare the result as a named variable for clarity.
let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
ToolSpec::Freeform(f) => f.name == "apply_patch",
_ => false,
});
if is_freeform_apply_patch_tool_present {
reserialize_shell_outputs(&mut input);
}
input
self.input.clone()
}
}
fn reserialize_shell_outputs(items: &mut [ResponseItem]) {
let mut shell_call_ids: HashSet<String> = HashSet::new();
items.iter_mut().for_each(|item| match item {
ResponseItem::LocalShellCall { call_id, id, .. } => {
if let Some(identifier) = call_id.clone().or_else(|| id.clone()) {
shell_call_ids.insert(identifier);
}
}
ResponseItem::CustomToolCall {
id: _,
status: _,
call_id,
name,
input: _,
} => {
if name == "apply_patch" {
shell_call_ids.insert(call_id.clone());
}
}
ResponseItem::FunctionCall { name, call_id, .. }
if is_shell_tool_name(name) || name == "apply_patch" =>
{
shell_call_ids.insert(call_id.clone());
}
ResponseItem::FunctionCallOutput {
call_id, output, ..
}
| ResponseItem::CustomToolCallOutput {
call_id, output, ..
} => {
if shell_call_ids.remove(call_id)
&& let Some(structured) = output
.text_content()
.and_then(parse_structured_shell_output)
{
output.body = FunctionCallOutputBody::Text(structured);
}
}
_ => {}
})
}
fn is_shell_tool_name(name: &str) -> bool {
name == "shell"
}
#[derive(Deserialize)]
struct ExecOutputJson {
output: String,
metadata: ExecOutputMetadataJson,
}
#[derive(Deserialize)]
struct ExecOutputMetadataJson {
exit_code: i32,
duration_seconds: f32,
}
fn parse_structured_shell_output(raw: &str) -> Option<String> {
let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?;
Some(build_structured_output(&parsed))
}
fn build_structured_output(parsed: &ExecOutputJson) -> String {
let mut sections = Vec::new();
sections.push(format!("Exit code: {}", parsed.metadata.exit_code));
sections.push(format!(
"Wall time: {} seconds",
parsed.metadata.duration_seconds
));
let mut output = parsed.output.clone();
if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) {
sections.push(format!("Total output lines: {total_lines}"));
output = stripped.to_string();
}
sections.push("Output:".to_string());
sections.push(output);
sections.join("\n")
}
fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
let after_prefix = output.strip_prefix("Total output lines: ")?;
let (total_segment, remainder) = after_prefix.split_once('\n')?;
let total_lines = total_segment.parse::<u32>().ok()?;
let remainder = remainder.strip_prefix('\n').unwrap_or(remainder);
Some((remainder, total_lines))
}
pub struct ResponseStream {
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
/// Signals the mapper task that the consumer stopped polling before the
-63
View File
@@ -3,7 +3,6 @@ use codex_api::ResponsesApiRequest;
use codex_api::TextControls;
use codex_api::create_text_param_for_request;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::FunctionCallOutputPayload;
use pretty_assertions::assert_eq;
use super::*;
@@ -166,65 +165,3 @@ fn serializes_flex_service_tier_when_set() {
Some("flex")
);
}
#[test]
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
let mut items = vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
name: None,
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
];
reserialize_shell_outputs(&mut items);
assert_eq!(
items,
vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
name: None,
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
]
);
}
+1
View File
@@ -192,6 +192,7 @@ pub(crate) async fn run_codex_thread_one_shot(
items: input,
final_output_json_schema,
responsesapi_client_metadata: None,
thread_settings: Default::default(),
})
.await?;
+38 -15
View File
@@ -63,7 +63,9 @@ pub struct ThreadConfigSnapshot {
pub profile_workspace_roots: Vec<AbsolutePathBuf>,
pub ephemeral: bool,
pub reasoning_effort: Option<ReasoningEffort>,
pub reasoning_summary: Option<ReasoningSummary>,
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
pub session_source: SessionSource,
pub thread_source: Option<ThreadSource>,
}
@@ -80,9 +82,9 @@ impl ThreadConfigSnapshot {
}
}
/// Turn context overrides that app-server validates before starting a turn.
/// Thread settings overrides that app-server validates before starting a turn.
#[derive(Clone, Default)]
pub struct CodexThreadTurnContextOverrides {
pub struct CodexThreadSettingsOverrides {
pub cwd: Option<PathBuf>,
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
@@ -144,7 +146,7 @@ impl CodexThread {
self.codex.session_loop_termination.clone().await;
}
pub(crate) fn emit_thread_resume_lifecycle(&self) {
pub(crate) async fn emit_thread_resume_lifecycle(&self) {
for contributor in self
.codex
.session
@@ -152,10 +154,12 @@ impl CodexThread {
.extensions
.thread_lifecycle_contributors()
{
contributor.on_thread_resume(codex_extension_api::ThreadResumeInput {
session_store: &self.codex.session.services.session_extension_data,
thread_store: &self.codex.session.services.thread_extension_data,
});
contributor
.on_thread_resume(codex_extension_api::ThreadResumeInput {
session_store: &self.codex.session.services.session_extension_data,
thread_store: &self.codex.session.services.thread_extension_data,
})
.await;
}
}
@@ -240,6 +244,17 @@ impl CodexThread {
.await
}
/// Injects hidden model-visible items into the currently active turn.
///
/// This is the runtime-owned counterpart to user-facing `steer_input`.
/// It returns the unchanged items when this thread has no active turn.
pub async fn inject_response_items_into_active_turn(
&self,
items: Vec<ResponseInputItem>,
) -> Result<(), Vec<ResponseInputItem>> {
self.codex.session.inject_response_items(items).await
}
pub async fn set_app_server_client_info(
&self,
app_server_client_name: Option<String>,
@@ -255,12 +270,20 @@ impl CodexThread {
.await
}
/// Validate persistent turn context overrides without committing them.
pub async fn validate_turn_context_overrides(
/// Preview persistent thread settings overrides without committing them.
pub async fn preview_thread_settings_overrides(
&self,
overrides: CodexThreadTurnContextOverrides,
) -> ConstraintResult<()> {
let CodexThreadTurnContextOverrides {
overrides: CodexThreadSettingsOverrides,
) -> ConstraintResult<ThreadConfigSnapshot> {
let updates = self.thread_settings_update(overrides).await;
self.codex.session.preview_settings(&updates).await
}
async fn thread_settings_update(
&self,
overrides: CodexThreadSettingsOverrides,
) -> SessionSettingsUpdate {
let CodexThreadSettingsOverrides {
cwd,
workspace_roots,
profile_workspace_roots,
@@ -287,7 +310,7 @@ impl CodexThread {
.with_updates(model, effort, /*developer_instructions*/ None)
};
let updates = SessionSettingsUpdate {
SessionSettingsUpdate {
cwd,
workspace_roots,
profile_workspace_roots,
@@ -302,8 +325,7 @@ impl CodexThread {
service_tier,
personality,
..Default::default()
};
self.codex.session.validate_settings(&updates).await
}
}
/// Use sparingly: this is intended to be removed soon.
@@ -381,6 +403,7 @@ impl CodexThread {
{
self.codex
.session
.input_queue
.queue_response_items_for_next_turn(items)
.await;
self.codex.session.maybe_start_turn_for_pending_work().await;
+1 -1
View File
@@ -100,6 +100,7 @@ pub(crate) async fn run_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
trace_id: turn_context.trace_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
@@ -282,7 +283,6 @@ async fn run_compact_task_inner_impl(
};
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
.await;
client_session.reset_websocket_session();
sess.recompute_token_usage(&turn_context).await;
sess.emit_turn_item_completed(&turn_context, compaction_item)
+2 -5
View File
@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::Prompt;
@@ -63,6 +62,7 @@ pub(crate) async fn run_remote_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
trace_id: turn_context.trace_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
@@ -174,9 +174,6 @@ async fn run_remote_compact_task_inner_impl(
let tool_router = built_tools(
sess.as_ref(),
turn_context.as_ref(),
&prompt_input,
&HashSet::new(),
/*skills_outcome*/ None,
&CancellationToken::new(),
)
.await?;
@@ -290,7 +287,7 @@ pub(crate) async fn process_compacted_history(
/// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary
/// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this
/// check.
fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, .. } if role == "developer" => false,
ResponseItem::Message { role, .. } if role == "user" => {
+343 -45
View File
@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::Prompt;
@@ -11,7 +10,14 @@ use crate::compact::compaction_status_from_result;
use crate::compact_remote::build_compact_request_log_data;
use crate::compact_remote::log_remote_compact_failure;
use crate::compact_remote::process_compacted_history;
use crate::compact_remote::should_keep_compacted_history_item;
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
use crate::hook_runtime::PostCompactHookOutcome;
use crate::hook_runtime::PreCompactHookOutcome;
use crate::hook_runtime::run_post_compact_hooks;
use crate::hook_runtime::run_pre_compact_hooks;
use crate::responses_retry::ResponsesStreamRequest;
use crate::responses_retry::handle_retryable_response_stream_error;
use crate::session::session::Session;
use crate::session::turn::built_tools;
use crate::session::turn_context::TurnContext;
@@ -24,17 +30,27 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TruncationPolicy;
use codex_protocol::protocol::TurnStartedEvent;
use codex_rollout_trace::CompactionCheckpointTracePayload;
use codex_rollout_trace::InferenceTraceContext;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_output_truncation::truncate_text;
use futures::StreamExt;
use futures::TryFutureExt;
use tokio_util::sync::CancellationToken;
use tracing::info;
// Mirror the current /responses/compact retained-message default while the
// server-side path remains the reference implementation.
const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000;
// Compact attempts can run much longer than normal turns, so keep the per-transport
// retry budget smaller than the general Responses stream retry budget.
const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2;
pub(crate) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
@@ -61,6 +77,7 @@ pub(crate) async fn run_remote_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
trace_id: turn_context.trace_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
@@ -93,10 +110,25 @@ async fn run_remote_compact_task_inner(
turn_context.as_ref(),
trigger,
reason,
CompactionImplementation::Responses,
CompactionImplementation::ResponsesCompactionV2,
phase,
)
.await;
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
match pre_compact_outcome {
PreCompactHookOutcome::Continue => {}
PreCompactHookOutcome::Stopped { reason } => {
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
attempt
.track(
sess.as_ref(),
codex_analytics::CompactionStatus::Interrupted,
Some(error),
)
.await;
return Err(CodexErr::TurnAborted);
}
}
let result = run_remote_compact_task_inner_impl(
sess,
turn_context,
@@ -104,13 +136,16 @@ async fn run_remote_compact_task_inner(
initial_context_injection,
)
.await;
attempt
.track(
sess.as_ref(),
compaction_status_from_result(&result),
result.as_ref().err().map(ToString::to_string),
)
.await;
let status = compaction_status_from_result(&result);
let error = result.as_ref().err().map(ToString::to_string);
if result.is_ok() {
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
attempt.track(sess.as_ref(), status, error).await;
return Err(CodexErr::TurnAborted);
}
}
attempt.track(sess.as_ref(), status, error.clone()).await;
if let Err(err) = result {
let event = EventMsg::Error(
err.to_error_event(Some("Error running remote compact task".to_string())),
@@ -158,9 +193,6 @@ async fn run_remote_compact_task_inner_impl(
let tool_router = built_tools(
sess.as_ref(),
turn_context.as_ref(),
&prompt_input,
&HashSet::new(),
/*skills_outcome*/ None,
&CancellationToken::new(),
)
.await?;
@@ -250,31 +282,71 @@ async fn run_remote_compaction_request_v2(
prompt: &Prompt,
turn_metadata_header: Option<&str>,
) -> CodexResult<(ResponseItem, String)> {
let stream = client_session
.stream(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
turn_metadata_header,
&InferenceTraceContext::disabled(),
)
.or_else(|err| async {
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
let compact_request_log_data =
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
log_remote_compact_failure(
turn_context,
&compact_request_log_data,
total_usage_breakdown,
&err,
);
Err(err)
})
.await?;
collect_compaction_output(stream).await
let max_retries = turn_context
.provider
.info()
.stream_max_retries()
.min(MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES);
let mut retries = 0;
loop {
let result = match client_session
.stream(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
turn_metadata_header,
&InferenceTraceContext::disabled(),
)
.await
{
Ok(stream) => collect_compaction_output(stream).await,
Err(err) => Err(err),
};
match result {
Ok(compaction_output) => return Ok(compaction_output),
Err(err) if !err.is_retryable() => {
log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await;
return Err(err);
}
Err(err) => {
if let Err(err) = handle_retryable_response_stream_error(
&mut retries,
max_retries,
err,
client_session,
sess,
turn_context,
ResponsesStreamRequest::RemoteCompactionV2,
)
.await
{
log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await;
return Err(err);
}
}
}
}
}
async fn log_remote_compaction_request_failure(
sess: &Session,
turn_context: &TurnContext,
prompt: &Prompt,
err: &CodexErr,
) {
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
let compact_request_log_data =
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
log_remote_compact_failure(
turn_context,
&compact_request_log_data,
total_usage_breakdown,
err,
);
}
async fn collect_compaction_output(
@@ -304,8 +376,9 @@ async fn collect_compaction_output(
}
let Some(response_id) = completed_response_id else {
return Err(CodexErr::Fatal(
return Err(CodexErr::Stream(
"remote compaction v2 stream closed before response.completed".to_string(),
None,
));
};
@@ -325,11 +398,14 @@ fn build_v2_compacted_history(
prompt_input: &[ResponseItem],
compaction_output: ResponseItem,
) -> Vec<ResponseItem> {
let mut retained = prompt_input
let retained = prompt_input
.iter()
.filter(|item| is_retained_for_remote_compaction_v2(item))
.filter(|item| should_keep_compacted_history_item(item))
.cloned()
.collect::<Vec<_>>();
let mut retained =
truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET);
retained.push(compaction_output);
retained
}
@@ -342,6 +418,98 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool {
matches!(role.as_str(), "user" | "developer" | "system")
}
fn truncate_retained_messages_for_remote_compaction(
items: Vec<ResponseItem>,
max_tokens: usize,
) -> Vec<ResponseItem> {
let mut remaining = max_tokens;
let mut truncated_reversed = Vec::with_capacity(items.len());
for item in items.into_iter().rev() {
if remaining == 0 {
continue;
}
let token_count = message_text_token_count(&item).max(1);
if token_count <= remaining {
truncated_reversed.push(item);
remaining = remaining.saturating_sub(token_count);
} else if let Some(truncated_item) =
truncate_message_text_to_token_budget(item, /*max_tokens*/ remaining)
{
truncated_reversed.push(truncated_item);
remaining = 0;
}
}
truncated_reversed.reverse();
truncated_reversed
}
fn message_text_token_count(item: &ResponseItem) -> usize {
let ResponseItem::Message { content, .. } = item else {
return 0;
};
content
.iter()
.map(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
approx_token_count(text)
}
ContentItem::InputImage { .. } => 0,
})
.sum()
}
fn truncate_message_text_to_token_budget(
item: ResponseItem,
max_tokens: usize,
) -> Option<ResponseItem> {
let ResponseItem::Message {
id,
role,
content,
phase,
} = item
else {
return Some(item);
};
let mut remaining = max_tokens;
let mut truncated_content = Vec::with_capacity(content.len());
for mut content_item in content {
match &mut content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
if remaining == 0 {
continue;
}
let token_count = approx_token_count(text);
if token_count <= remaining {
remaining = remaining.saturating_sub(token_count);
} else {
*text = truncate_text(text, TruncationPolicy::Tokens(remaining));
remaining = 0;
}
if !text.is_empty() {
truncated_content.push(content_item);
}
}
ContentItem::InputImage { .. } => truncated_content.push(content_item),
}
}
if truncated_content.is_empty() {
return None;
}
Some(ResponseItem::Message {
id,
role,
content: truncated_content,
phase,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -377,7 +545,7 @@ mod tests {
}
#[test]
fn build_v2_compacted_history_matches_prod_retention_shape() {
fn build_v2_compacted_history_filters_to_installed_retention_shape() {
let input = vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
@@ -403,15 +571,145 @@ mod tests {
assert_eq!(
history,
vec![message("user", "user", /*phase*/ None), output]
);
}
#[test]
fn build_v2_compacted_history_discards_messages_before_truncating() {
let old = message("user", "old", /*phase*/ None);
let new = message("user", "new", /*phase*/ None);
let huge_developer_message = "d".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4);
let huge_contextual_message = format!(
"<environment_context>\n{}\n</environment_context>",
"c".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4)
);
let input = vec![
old.clone(),
message("developer", &huge_developer_message, /*phase*/ None),
message("user", &huge_contextual_message, /*phase*/ None),
new.clone(),
];
let output = ResponseItem::Compaction {
encrypted_content: "new".to_string(),
};
let history = build_v2_compacted_history(&input, output.clone());
assert_eq!(history, vec![old, new, output]);
}
#[test]
fn retained_history_truncation_keeps_newest_messages_first() {
let middle = message("user", "middle1234", /*phase*/ None);
let new = message("user", "new", /*phase*/ None);
let retained = vec![
message("user", "old-old", /*phase*/ None),
middle,
new.clone(),
];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 3);
assert_eq!(
truncated,
vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
message("user", "user", /*phase*/ None),
output,
message("user", "midd…1 tokens truncated…1234", /*phase*/ None),
new,
]
);
}
#[test]
fn retained_history_truncation_preserves_images_and_truncates_later_text_parts() {
let item = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "abcdef".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
},
ContentItem::OutputText {
text: "uvwxyz".to_string(),
},
],
phase: None,
};
let truncated =
truncate_retained_messages_for_remote_compaction(vec![item], /*max_tokens*/ 3);
assert_eq!(
truncated,
vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "abcdef".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
},
ContentItem::OutputText {
text: "uv…1 tokens truncated…yz".to_string(),
},
],
phase: None,
}]
);
}
#[test]
fn retained_history_truncation_charges_image_only_messages() {
let image_only_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
}],
phase: None,
};
let newest = message("user", "new", /*phase*/ None);
let retained = vec![
message("user", "old", /*phase*/ None),
image_only_message.clone(),
newest.clone(),
];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 2);
assert_eq!(truncated, vec![image_only_message, newest]);
}
#[test]
fn retained_history_truncation_drops_image_only_messages_after_budget_is_spent() {
let image_only_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
}],
phase: None,
};
let newest = message("user", "new", /*phase*/ None);
let retained = vec![image_only_message, newest.clone()];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 1);
assert_eq!(truncated, vec![newest]);
}
#[tokio::test]
async fn collect_compaction_output_accepts_additional_output_items() {
let compaction = ResponseItem::Compaction {
+277 -17
View File
@@ -27,9 +27,10 @@ use codex_config::loader::load_requirements_toml;
use codex_exec_server::LOCAL_FS;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -821,6 +822,7 @@ flag = false
async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()>
{
use base64::Engine;
use codex_protocol::protocol::SandboxPolicy;
let Some(home) = dirs::home_dir() else {
return Ok(());
@@ -913,14 +915,7 @@ allowed_sandbox_modes = ["read-only"]
state
.requirements()
.permission_profile
.can_set(&PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
))
.can_set(&PermissionProfile::workspace_write())
.is_err()
);
@@ -1102,9 +1097,12 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -1160,9 +1158,12 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -1233,11 +1234,9 @@ allowed_sandbox_modes = ["read-only"]
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
assert_eq!(
config_requirements.permission_profile.can_set(
&PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_workspace_write_policy()
)
),
config_requirements
.permission_profile
.can_set(&PermissionProfile::workspace_write()),
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: "WorkspaceWrite".into(),
@@ -1369,9 +1368,12 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -1417,6 +1419,264 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
Ok(())
}
#[tokio::test]
async fn system_requirements_define_managed_permission_profiles() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
default_permissions = "managed-standard"
"#,
)
.await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard]
extends = ":workspace"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.config_layer_stack
.requirements_toml()
.allowed_permissions,
Some(vec!["managed-standard".to_string()])
);
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-standard".to_string())
);
Ok(())
}
#[tokio::test]
async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> {
for (trust_level, expected_profile) in [
(
Some(TrustLevel::Trusted),
if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
},
),
(
Some(TrustLevel::Untrusted),
if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
},
),
(None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY),
] {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
if let Some(trust_level) = trust_level {
make_config_for_test(
&codex_home,
tmp.path(),
trust_level,
/*project_root_markers*/ None,
)
.await?;
}
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard.filesystem]
":workspace_roots" = "read"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some(expected_profile.to_string()),
"trust level {trust_level:?}",
);
}
Ok(())
}
#[tokio::test]
async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
default_permissions = ":workspace"
"#,
)
.await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard.filesystem]
":workspace_roots" = "read"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
);
Ok(())
}
#[tokio::test]
async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()>
{
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
default_permissions = "managed-build"
"#,
)
.await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard", "managed-build"]
[permissions.managed-standard]
extends = ":read-only"
[permissions.managed-build]
extends = ":workspace"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-build".to_string())
);
Ok(())
}
#[tokio::test]
async fn system_requirements_warn_for_disallowed_explicit_permission_override() -> anyhow::Result<()>
{
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard]
extends = ":workspace"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.harness_overrides(ConfigOverrides {
default_permissions: Some("managed-build".to_string()),
..ConfigOverrides::default()
})
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-standard".to_string())
);
assert!(
config.startup_warnings.iter().any(|warning| warning
.contains("Configured value for `permission_profile` is disallowed by requirements")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> {
let tmp = tempdir()?;
@@ -1570,9 +1830,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::
layers
.requirements()
.permission_profile
.can_set(&PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_workspace_write_policy()
))
.can_set(&PermissionProfile::workspace_write())
.is_ok()
);
@@ -2353,6 +2611,7 @@ model = "project-model"
model_instructions_file = "instructions.md"
openai_base_url = "https://attacker.example/v1"
chatgpt_base_url = "https://attacker.example/backend-api"
apps_mcp_product_sku = "attacker"
model_provider = "attacker"
notify = ["sh", "-c", "echo attacker"]
profile = "attacker"
@@ -2404,6 +2663,7 @@ wire_api = "responses"
let ignored_project_config_keys = vec![
"openai_base_url",
"chatgpt_base_url",
"apps_mcp_product_sku",
"model_provider",
"model_providers",
"notify",
File diff suppressed because it is too large Load Diff
+40 -150
View File
@@ -40,8 +40,6 @@ pub enum ConfigEdit {
SetNoticeHideFullAccessWarning(bool),
/// Toggle the Windows world-writable directories warning acknowledgement flag.
SetNoticeHideWorldWritableWarning(bool),
/// Toggle the opt-out marker for Codex-managed fast defaults.
SetNoticeFastDefaultOptOut(bool),
/// Toggle the rate limit model nudge acknowledgement flag.
SetNoticeHideRateLimitModelNudge(bool),
/// Toggle the model migration prompt acknowledgement flag.
@@ -302,8 +300,8 @@ mod document_helpers {
if !config.enabled {
entry["enabled"] = value(false);
}
if let Some(environment) = &config.experimental_environment {
entry["experimental_environment"] = value(environment.clone());
if !config.is_local_environment() {
entry["environment_id"] = value(config.environment_id.clone());
}
if config.required {
entry["required"] = value(true);
@@ -515,13 +513,6 @@ mod document_helpers {
struct ConfigDocument {
doc: DocumentMut,
profile: Option<String>,
}
#[derive(Copy, Clone)]
enum Scope {
Global,
Profile,
}
#[derive(Copy, Clone)]
@@ -531,27 +522,29 @@ enum TraversalMode {
}
impl ConfigDocument {
fn new(doc: DocumentMut, profile: Option<String>) -> Self {
Self { doc, profile }
fn new(doc: DocumentMut) -> Self {
Self { doc }
}
fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result<bool> {
match edit {
ConfigEdit::SetModel { model, effort } => Ok({
let mut mutated = false;
mutated |= self.write_profile_value(
mutated |= self.write_optional_value(
&["model"],
model.as_ref().map(|model_value| value(model_value.clone())),
);
mutated |= self.write_profile_value(
mutated |= self.write_optional_value(
&["model_reasoning_effort"],
effort.map(|effort| value(effort.to_string())),
);
mutated
}),
ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value(
ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_optional_value(
&["service_tier"],
service_tier.as_ref().map(|service_tier| {
// Keep the legacy config spelling stable. Runtime values use
// `priority`, but config.toml continues to store it as `fast`.
let config_value = match ServiceTier::from_request_value(service_tier) {
Some(ServiceTier::Fast) => "fast",
Some(ServiceTier::Flex) => "flex",
@@ -560,40 +553,30 @@ impl ConfigDocument {
value(config_value)
}),
)),
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value(
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_optional_value(
&["personality"],
personality.map(|personality| value(personality.to_string())),
)),
ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "hide_full_access_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "fast_default_opt_out"],
value(*opted_out),
)),
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => {
Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, migration_config.as_str()],
value(*acknowledged),
))
}
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self
.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
@@ -603,7 +586,6 @@ impl ConfigDocument {
)),
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => {
Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
@@ -616,7 +598,6 @@ impl ConfigDocument {
project,
acknowledged,
) => Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
@@ -629,7 +610,6 @@ impl ConfigDocument {
project,
timestamp,
) => Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
@@ -639,7 +619,6 @@ impl ConfigDocument {
value(*timestamp),
)),
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
value(to.clone()),
)),
@@ -668,20 +647,26 @@ impl ConfigDocument {
}
}
fn write_profile_value(&mut self, segments: &[&str], value: Option<TomlItem>) -> bool {
fn write_optional_value(&mut self, segments: &[&str], value: Option<TomlItem>) -> bool {
match value {
Some(item) => self.write_value(Scope::Profile, segments, item),
None => self.clear(Scope::Profile, segments),
Some(item) => self.write_value(segments, item),
None => self.clear(segments),
}
}
fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool {
let resolved = self.scoped_segments(scope, segments);
fn write_value(&mut self, segments: &[&str], value: TomlItem) -> bool {
let resolved = segments
.iter()
.map(|segment| (*segment).to_string())
.collect::<Vec<_>>();
self.insert(&resolved, value)
}
fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool {
let resolved = self.scoped_segments(scope, segments);
fn clear(&mut self, segments: &[&str]) -> bool {
let resolved = segments
.iter()
.map(|segment| (*segment).to_string())
.collect::<Vec<_>>();
self.remove(&resolved)
}
@@ -714,7 +699,6 @@ impl ConfigDocument {
.filter(|disabled_tool| seen.insert(disabled_tool.clone()))
.collect::<Vec<_>>();
self.write_value(
Scope::Global,
&["tool_suggest", "disabled_tools"],
document_helpers::tool_suggest_disabled_tools_value(&disabled_tools),
)
@@ -726,7 +710,7 @@ impl ConfigDocument {
fn replace_mcp_servers(&mut self, servers: &BTreeMap<String, McpServerConfig>) -> bool {
if servers.is_empty() {
return self.clear(Scope::Global, &["mcp_servers"]);
return self.clear(&["mcp_servers"]);
}
let root = self.doc.as_table_mut();
@@ -888,26 +872,6 @@ impl ConfigDocument {
mutated
}
fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec<String> {
let resolved: Vec<String> = segments
.iter()
.map(|segment| (*segment).to_string())
.collect();
if matches!(scope, Scope::Profile)
&& resolved.first().is_none_or(|segment| segment != "profiles")
&& let Some(profile) = self.profile.as_deref()
{
let mut scoped = Vec::with_capacity(resolved.len() + 2);
scoped.push("profiles".to_string());
scoped.push(profile.to_string());
scoped.extend(resolved);
return scoped;
}
resolved
}
fn insert(&mut self, segments: &[String], value: TomlItem) -> bool {
let Some((last, parents)) = segments.split_last() else {
return false;
@@ -1035,18 +999,13 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele
}
/// Persist edits using a blocking strategy.
pub fn apply_blocking(
codex_home: &Path,
profile: Option<&str>,
edits: &[ConfigEdit],
) -> anyhow::Result<()> {
pub fn apply_blocking(codex_home: &Path, edits: &[ConfigEdit]) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
apply_blocking_to_resolved_file(&config_path, profile, edits)
apply_blocking_to_resolved_file(&config_path, edits)
}
fn apply_blocking_to_resolved_file(
resolved_config_file: &Path,
legacy_profile: Option<&str>,
edits: &[ConfigEdit],
) -> anyhow::Result<()> {
if edits.is_empty() {
@@ -1069,13 +1028,7 @@ fn apply_blocking_to_resolved_file(
serialized.parse::<DocumentMut>()?
};
let profile = legacy_profile.map(ToOwned::to_owned).or_else(|| {
doc.get("profile")
.and_then(|item| item.as_str())
.map(ToOwned::to_owned)
});
let mut document = ConfigDocument::new(doc, profile);
let mut document = ConfigDocument::new(doc);
let mut mutated = false;
for edit in edits {
@@ -1098,29 +1051,18 @@ fn apply_blocking_to_resolved_file(
/// Persist edits asynchronously by offloading the blocking writer.
///
/// `profile` selects a legacy `[profiles.<name>]` section inside
/// `$CODEX_HOME/config.toml`; profile-v2 callers should resolve their target
/// file before constructing a [ConfigEditsBuilder].
pub async fn apply(
codex_home: &Path,
profile: Option<&str>,
edits: Vec<ConfigEdit>,
) -> anyhow::Result<()> {
pub async fn apply(codex_home: &Path, edits: Vec<ConfigEdit>) -> anyhow::Result<()> {
let codex_home = codex_home.to_path_buf();
let config_path = codex_home.join(CONFIG_TOML_FILE);
let profile = profile.map(ToOwned::to_owned);
task::spawn_blocking(move || {
apply_blocking_to_resolved_file(&config_path, profile.as_deref(), &edits)
})
.await
.context("config persistence task panicked")?
task::spawn_blocking(move || apply_blocking_to_resolved_file(&config_path, &edits))
.await
.context("config persistence task panicked")?
}
/// Fluent builder to batch config edits and apply them atomically.
#[derive(Default)]
pub struct ConfigEditsBuilder {
config_path: PathBuf,
profile: Option<String>,
edits: Vec<ConfigEdit>,
}
@@ -1141,16 +1083,10 @@ impl ConfigEditsBuilder {
pub fn for_config_path(config_path: &Path) -> Self {
Self {
config_path: config_path.to_path_buf(),
profile: None,
edits: Vec::new(),
}
}
pub fn with_profile(mut self, profile: Option<&str>) -> Self {
self.profile = profile.map(ToOwned::to_owned);
self
}
pub fn set_model(mut self, model: Option<&str>, effort: Option<ReasoningEffort>) -> Self {
self.edits.push(ConfigEdit::SetModel {
model: model.map(ToOwned::to_owned),
@@ -1182,12 +1118,6 @@ impl ConfigEditsBuilder {
self
}
pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out));
self
}
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
@@ -1259,27 +1189,16 @@ impl ConfigEditsBuilder {
/// Enable or disable a feature flag by key under the `[features]` table.
///
/// Disabling a default-false feature clears the root-scoped key instead of
/// Disabling a default-false feature clears the key instead of
/// persisting `false`, so the config does not pin the feature once it
/// graduates to globally enabled. Profile-scoped disables still persist
/// `false` so they can override an inherited root enable.
/// graduates to globally enabled.
pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self {
let profile_scoped = self.profile.is_some();
let segments = if let Some(profile) = self.profile.as_ref() {
vec![
"profiles".to_string(),
profile.clone(),
"features".to_string(),
key.to_string(),
]
} else {
vec!["features".to_string(), key.to_string()]
};
let segments = vec!["features".to_string(), key.to_string()];
let is_default_false_feature = FEATURES
.iter()
.find(|spec| spec.key == key)
.is_some_and(|spec| !spec.default_enabled);
if enabled || profile_scoped || !is_default_false_feature {
if enabled || !is_default_false_feature {
self.edits.push(ConfigEdit::SetPath {
segments,
value: value(enabled),
@@ -1291,18 +1210,8 @@ impl ConfigEditsBuilder {
}
pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self {
let segments = if let Some(profile) = self.profile.as_ref() {
vec![
"profiles".to_string(),
profile.clone(),
"windows".to_string(),
"sandbox".to_string(),
]
} else {
vec!["windows".to_string(), "sandbox".to_string()]
};
self.edits.push(ConfigEdit::SetPath {
segments,
segments: vec!["windows".to_string(), "sandbox".to_string()],
value: value(mode),
});
self
@@ -1350,34 +1259,15 @@ impl ConfigEditsBuilder {
"elevated_windows_sandbox",
"enable_experimental_windows_sandbox",
] {
let mut segments = vec!["features".to_string(), key.to_string()];
if let Some(profile) = self.profile.as_ref() {
segments = vec![
"profiles".to_string(),
profile.clone(),
"features".to_string(),
key.to_string(),
];
}
let segments = vec!["features".to_string(), key.to_string()];
self.edits.push(ConfigEdit::ClearPath { segments });
}
self
}
pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self {
let segments = if let Some(profile) = self.profile.as_ref() {
vec![
"profiles".to_string(),
profile.clone(),
"tui".to_string(),
"session_picker_view".to_string(),
]
} else {
vec!["tui".to_string(), "session_picker_view".to_string()]
};
self.edits.push(ConfigEdit::SetPath {
segments,
segments: vec!["tui".to_string(), "session_picker_view".to_string()],
value: value(mode.to_string()),
});
self
@@ -1393,13 +1283,13 @@ impl ConfigEditsBuilder {
/// Apply edits on a blocking thread.
pub fn apply_blocking(self) -> anyhow::Result<()> {
apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits)
apply_blocking_to_resolved_file(&self.config_path, &self.edits)
}
/// Apply edits asynchronously via a blocking offload.
pub async fn apply(self) -> anyhow::Result<()> {
task::spawn_blocking(move || {
apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits)
apply_blocking_to_resolved_file(&self.config_path, &self.edits)
})
.await
.context("config persistence task panicked")?
+41 -113
View File
@@ -4,6 +4,7 @@ use codex_config::types::McpServerOAuthConfig;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::SessionPickerViewMode;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -19,7 +20,6 @@ fn blocking_set_model_top_level() {
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.4".to_string()),
effort: Some(ReasoningEffort::High),
@@ -34,6 +34,20 @@ model_reasoning_effort = "high"
assert_eq!(contents, expected);
}
#[test]
fn set_service_tier_saves_default_as_default() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.set_service_tier(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, "service_tier = \"default\"\n");
}
#[test]
fn set_service_tier_saves_priority_as_fast() {
let tmp = tempdir().expect("tmpdir");
@@ -96,24 +110,6 @@ session_picker_view = "dense"
assert_eq!(contents, expected);
}
#[test]
fn session_picker_view_builder_respects_active_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_profile(Some("work"))
.set_session_picker_view(SessionPickerViewMode::Dense)
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[profiles.work.tui]
session_picker_view = "dense"
"#;
assert_eq!(contents, expected);
}
#[test]
fn keymap_binding_edit_writes_root_action_binding() {
let tmp = tempdir().expect("tmpdir");
@@ -365,7 +361,7 @@ enabled = false
}
#[test]
fn blocking_set_model_preserves_inline_table_contents() {
fn blocking_set_model_ignores_inline_legacy_profile_contents() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
@@ -381,7 +377,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("o4-mini".to_string()),
effort: None,
@@ -392,7 +387,12 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let value: TomlValue = toml::from_str(&raw).expect("parse config");
// Ensure sandbox_mode is preserved under profiles.fast and model updated.
assert_eq!(
value.get("model").and_then(TomlValue::as_str),
Some("o4-mini")
);
// Legacy profile values stay untouched when root settings are updated.
let profiles_tbl = value
.get("profiles")
.and_then(|v| v.as_table())
@@ -407,7 +407,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
);
assert_eq!(
fast_tbl.get("model").and_then(|v| v.as_str()),
Some("o4-mini")
Some("gpt-4o")
);
}
@@ -426,7 +426,6 @@ fn blocking_set_model_writes_through_symlink_chain() {
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.4".to_string()),
effort: Some(ReasoningEffort::High),
@@ -459,7 +458,6 @@ fn blocking_set_model_replaces_symlink_on_cycle() {
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.4".to_string()),
effort: None,
@@ -498,7 +496,6 @@ network_access = false
apply_blocking(
codex_home,
/*profile*/ None,
&[
ConfigEdit::SetPath {
segments: vec![
@@ -538,7 +535,7 @@ network_access = true
}
#[test]
fn blocking_clear_model_removes_inline_table_entry() {
fn blocking_clear_model_does_not_follow_legacy_active_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
@@ -553,7 +550,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: None,
effort: Some(ReasoningEffort::High),
@@ -564,15 +560,14 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"profile = "fast"
[profiles.fast]
sandbox_mode = "strict"
profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
model_reasoning_effort = "high"
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_model_scopes_to_active_profile() {
fn blocking_set_model_does_not_follow_legacy_active_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
@@ -587,7 +582,6 @@ model_reasoning_effort = "low"
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetModel {
model: Some("o5-preview".to_string()),
effort: Some(ReasoningEffort::Minimal),
@@ -597,39 +591,11 @@ model_reasoning_effort = "low"
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"profile = "team"
model = "o5-preview"
model_reasoning_effort = "minimal"
[profiles.team]
model_reasoning_effort = "minimal"
model = "o5-preview"
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_model_with_explicit_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[profiles."team a"]
model = "gpt-5.4"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
Some("team a"),
&[ConfigEdit::SetModel {
model: Some("o4-mini".to_string()),
effort: None,
}],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[profiles."team a"]
model = "o4-mini"
model_reasoning_effort = "low"
"#;
assert_eq!(contents, expected);
}
@@ -651,7 +617,6 @@ existing = "value"
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideFullAccessWarning(true)],
)
.expect("persist");
@@ -681,7 +646,6 @@ existing = "value"
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)],
)
.expect("persist");
@@ -707,7 +671,6 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
"hide_gpt5_1_migration_prompt".to_string(),
true,
@@ -736,7 +699,6 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
"hide_gpt-5.1-codex-max_migration_prompt".to_string(),
true,
@@ -765,7 +727,6 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::RecordModelMigrationSeen {
from: "gpt-5.2".to_string(),
to: "gpt-5.4".to_string(),
@@ -796,7 +757,6 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
true,
)],
@@ -826,7 +786,6 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
"/Users/alexsong/code/skills".to_string(),
@@ -859,7 +818,6 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)],
)
.expect("persist");
@@ -887,7 +845,6 @@ existing = "value"
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
"/Users/alexsong/code/skills".to_string(),
@@ -930,7 +887,7 @@ fn blocking_replace_mcp_servers_round_trips() {
env_vars: vec!["FOO".into()],
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: true,
@@ -960,7 +917,7 @@ fn blocking_replace_mcp_servers_round_trips() {
),
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -981,7 +938,6 @@ fn blocking_replace_mcp_servers_round_trips() {
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)
.expect("persist");
@@ -1032,7 +988,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1054,12 +1010,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
},
);
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = "\
@@ -1097,7 +1048,7 @@ foo = { command = "cmd" }
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1114,12 +1065,7 @@ foo = { command = "cmd" }
},
);
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
apply_blocking(codex_home, &[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]
@@ -1152,7 +1098,7 @@ foo = { command = "cmd" } # keep me
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -1169,12 +1115,7 @@ foo = { command = "cmd" } # keep me
},
);
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
apply_blocking(codex_home, &[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]
@@ -1206,7 +1147,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1223,12 +1164,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
},
);
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
apply_blocking(codex_home, &[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]
@@ -1261,7 +1197,7 @@ foo = { command = "cmd" }
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -1278,12 +1214,7 @@ foo = { command = "cmd" }
},
);
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(servers)],
)
.expect("persist");
apply_blocking(codex_home, &[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]
@@ -1300,7 +1231,6 @@ fn blocking_clear_path_noop_when_missing() {
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ClearPath {
segments: vec!["missing".to_string()],
}],
@@ -1321,7 +1251,6 @@ fn blocking_set_path_updates_notifications() {
let item = value(false);
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "notifications".to_string()],
value: item,
@@ -1503,7 +1432,6 @@ fn replace_mcp_servers_blocking_clears_table_when_empty() {
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::ReplaceMcpServers(BTreeMap::new())],
)
.expect("persist");
+9 -65
View File
@@ -9,7 +9,6 @@ use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::profile_toml::ConfigProfile;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
@@ -269,27 +268,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
enabled,
));
}
for (profile_name, profile) in &cfg.profiles {
if let Some(features) = profile.features.as_ref() {
for (key, enabled) in features.entries() {
if let Some(feature) = feature_for_key(&key) {
explicit_settings.push((
format!("profiles.{profile_name}.features.{key}"),
feature,
enabled,
));
}
}
}
if let Some(enabled) = profile.experimental_use_unified_exec_tool {
explicit_settings.push((
format!("profiles.{profile_name}.experimental_use_unified_exec_tool"),
Feature::UnifiedExec,
enabled,
));
}
}
explicit_settings
}
@@ -339,47 +317,13 @@ pub(crate) fn validate_feature_requirements_in_config_toml(
cfg: &ConfigToml,
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<()> {
fn validate_profile(
cfg: &ConfigToml,
profile_name: Option<&str>,
profile: &ConfigProfile,
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<()> {
let configured_features = Features::from_sources(
FeatureConfigSource {
features: cfg.features.as_ref(),
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
},
FeatureConfigSource {
features: profile.features.as_ref(),
experimental_use_unified_exec_tool: profile.experimental_use_unified_exec_tool,
},
FeatureOverrides::default(),
);
ManagedFeatures::from_configured(configured_features, feature_requirements.cloned())
.map(|_| ())
.map_err(|err| {
if let Some(profile_name) = profile_name {
std::io::Error::new(
err.kind(),
format!(
"invalid feature configuration for profile `{profile_name}`: {err}"
),
)
} else {
err
}
})
}
validate_profile(
cfg,
/*profile_name*/ None,
&ConfigProfile::default(),
feature_requirements,
)?;
for (profile_name, profile) in &cfg.profiles {
validate_profile(cfg, Some(profile_name), profile, feature_requirements)?;
}
Ok(())
let configured_features = Features::from_sources(
FeatureConfigSource {
features: cfg.features.as_ref(),
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
},
FeatureConfigSource::default(),
FeatureOverrides::default(),
);
ManagedFeatures::from_configured(configured_features, feature_requirements.cloned()).map(|_| ())
}
File diff suppressed because it is too large Load Diff
@@ -5,13 +5,8 @@ use codex_network_proxy::NetworkDomainPermission;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
}
fn domain_permissions(
entries: impl IntoIterator<Item = (&'static str, NetworkDomainPermissionToml)>,
) -> NetworkDomainPermissionsToml {
@@ -62,7 +57,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()),
&PermissionProfile::read_only(),
)
.expect("config should stay within the managed allowlist");
@@ -97,7 +92,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
&PermissionProfile::workspace_write(),
)
.expect("managed allowlist should not erase a user deny");
@@ -129,7 +124,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
&PermissionProfile::workspace_write(),
)
.expect("managed baseline should still allow user edits");
@@ -207,7 +202,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
&PermissionProfile::Disabled,
)
.expect("yolo mode should pin the effective policy to the managed baseline");
@@ -241,7 +236,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
&PermissionProfile::workspace_write(),
)
.expect("managed baseline should still load");
@@ -270,7 +265,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
&PermissionProfile::workspace_write(),
)
.expect("managed-only allowlist should still load");
@@ -300,7 +295,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
&PermissionProfile::workspace_write(),
)
.expect("managed-only mode should treat missing managed allowlist as empty");
@@ -324,7 +319,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
&PermissionProfile::Disabled,
)
.expect("managed-only mode should treat missing managed allowlist as empty");
@@ -351,7 +346,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
&PermissionProfile::Disabled,
)
.expect("deny-only requirements should not constrain the allowlist");
@@ -384,7 +379,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
&PermissionProfile::Disabled,
)
.expect("allow-only requirements should not constrain the denylist");
@@ -417,7 +412,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
&PermissionProfile::workspace_write(),
)
.expect("default mode should merge managed and user deny entries");
@@ -452,7 +447,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
&PermissionProfile::workspace_write(),
)
.expect("managed baseline should still allow user edits");
+77 -39
View File
@@ -13,6 +13,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::ResolvedPermissionProfileToml;
use codex_config::permissions_toml::WorkspaceRootsToml;
use codex_config::types::SandboxWorkspaceWrite;
use codex_features::NetworkProxyConfigToml;
@@ -190,15 +191,29 @@ pub(crate) fn apply_network_proxy_feature_config(
.apply_to_network_proxy_config(config);
}
pub(crate) fn resolve_permission_profile<'a>(
permissions: &'a PermissionsToml,
pub(crate) fn resolve_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
) -> io::Result<&'a PermissionProfileToml> {
permissions.entries.get(profile_name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("default_permissions refers to undefined profile `{profile_name}`"),
)
) -> io::Result<ResolvedPermissionProfileToml> {
permissions
.resolve_profile(profile_name, extensible_builtin_parent_profile_marker)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))
}
/// Built-in parents provide their runtime permissions below. Resolution only
/// needs an empty profile marker so inheritance can terminate while preserving
/// the built-in parent id in `inherited_profile_names`.
fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option<PermissionProfileToml> {
matches!(
profile_name,
BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE
)
.then_some(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: None,
network: None,
})
}
@@ -217,7 +232,7 @@ pub(crate) fn network_proxy_config_for_profile_selection(
"default_permissions requires a `[permissions]` table",
)
})?;
let profile = resolve_permission_profile(permissions, profile_name)?;
let profile = resolve_permission_profile(permissions, profile_name)?.profile;
Ok(network_proxy_config_from_profile_network(
profile.network.as_ref(),
))
@@ -229,11 +244,27 @@ pub(crate) fn compile_permission_profile(
policy_cwd: &Path,
startup_warnings: &mut Vec<String>,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
let profile = resolve_permission_profile(permissions, profile_name)?;
let mut entries = Vec::new();
let ResolvedPermissionProfileToml {
profile,
inherited_profile_names,
} = resolve_permission_profile(permissions, profile_name)?;
let base_permissions = inherited_profile_names.iter().find_map(|name| {
match name.as_str() {
BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()),
BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()),
_ => None,
}
.map(|profile| profile.to_runtime_permissions())
});
let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions
.unwrap_or_else(|| {
(
FileSystemSandboxPolicy::restricted(Vec::new()),
NetworkSandboxPolicy::Restricted,
)
});
if let Some(filesystem) = profile.filesystem.as_ref() {
if filesystem.is_empty() {
if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() {
push_warning(
startup_warnings,
missing_filesystem_entries_warning(profile_name),
@@ -244,7 +275,7 @@ pub(crate) fn compile_permission_profile(
push_warning(
startup_warnings,
format!(
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported."
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `deny` globs are supported."
),
);
}
@@ -258,15 +289,17 @@ pub(crate) fn compile_permission_profile(
}
}
for (path, permission) in &filesystem.entries {
entries.extend(compile_filesystem_permission(
path,
permission,
policy_cwd,
startup_warnings,
)?);
file_system_sandbox_policy
.entries
.extend(compile_filesystem_permission(
path,
permission,
policy_cwd,
startup_warnings,
)?);
}
}
} else {
} else if file_system_sandbox_policy.entries.is_empty() {
push_warning(
startup_warnings,
missing_filesystem_entries_warning(profile_name),
@@ -278,10 +311,11 @@ pub(crate) fn compile_permission_profile(
.as_ref()
.and_then(|filesystem| filesystem.glob_scan_max_depth),
)?;
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries);
file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth;
if let Some(glob_scan_max_depth) = glob_scan_max_depth {
file_system_sandbox_policy.glob_scan_max_depth = Some(glob_scan_max_depth);
}
let network_sandbox_policy =
compile_network_sandbox_policy(profile.network.as_ref(), base_network_sandbox_policy);
Ok((file_system_sandbox_policy, network_sandbox_policy))
}
@@ -324,7 +358,7 @@ pub(crate) fn compile_permission_profile_workspace_roots(
})?;
let profile = resolve_permission_profile(permissions, profile_name)?;
Ok(compile_workspace_roots(
profile.workspace_roots.as_ref(),
profile.profile.workspace_roots.as_ref(),
policy_cwd,
))
}
@@ -341,7 +375,7 @@ fn compile_workspace_roots(
})
}
fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> {
pub(crate) fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> {
if profile_name.starts_with(':') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -383,14 +417,18 @@ pub(crate) fn get_readable_roots_required_for_codex_runtime(
readable_roots
}
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
fn compile_network_sandbox_policy(
network: Option<&NetworkToml>,
base_network_sandbox_policy: NetworkSandboxPolicy,
) -> NetworkSandboxPolicy {
let Some(network) = network else {
return NetworkSandboxPolicy::Restricted;
return base_network_sandbox_policy;
};
match network.enabled {
Some(true) => NetworkSandboxPolicy::Enabled,
_ => NetworkSandboxPolicy::Restricted,
Some(false) => NetworkSandboxPolicy::Restricted,
None => base_network_sandbox_policy,
}
}
@@ -415,7 +453,7 @@ fn compile_filesystem_permission(
Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true,
Some(_) => false,
};
if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern {
if has_glob && *access == FileSystemAccessMode::Deny && can_compile_as_pattern {
// Scoped glob syntax is a first-class filesystem policy
// pattern entry. Literal scoped paths continue through the
// exact-path parser so existing path semantics stay intact.
@@ -450,7 +488,7 @@ fn compile_filesystem_access_path(
return compile_filesystem_path(path, startup_warnings);
}
if access == FileSystemAccessMode::None {
if access == FileSystemAccessMode::Deny {
// At this point `path` is an unscoped filesystem table key. Top-level
// glob deny entries still go through the absolute-path parser before
// becoming policy patterns; relative project-root glob syntax is
@@ -521,10 +559,10 @@ fn compile_scoped_filesystem_pattern(
// Pattern entries currently mean deny-read only. Supporting broader access
// modes here would imply glob-based read/write allow semantics that the
// sandbox policy does not express yet.
if access != FileSystemAccessMode::None {
if access != FileSystemAccessMode::Deny {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem glob subpath `{subpath}` only supports `none` access"),
format!("filesystem glob subpath `{subpath}` only supports `deny` access"),
));
}
let subpath = parse_relative_subpath(subpath)?;
@@ -560,7 +598,7 @@ fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io:
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access"
"filesystem glob path `{path}` only supports `deny` access; use an exact path or trailing `/**` for `{access}` subtree access"
),
))
}
@@ -570,7 +608,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) ->
for (path, permission) in &filesystem.entries {
match permission {
FilesystemPermissionToml::Access(access) => {
if *access != FileSystemAccessMode::None
if *access != FileSystemAccessMode::Deny
&& contains_glob_chars(remove_trailing_glob_suffix(path))
{
patterns.push(path.clone());
@@ -578,7 +616,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) ->
}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
if *access != FileSystemAccessMode::None
if *access != FileSystemAccessMode::Deny
&& contains_glob_chars(remove_trailing_glob_suffix(subpath))
{
patterns.push(format!("{path}/{subpath}"));
@@ -598,7 +636,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -
let mut patterns = Vec::new();
for (path, permission) in &filesystem.entries {
match permission {
FilesystemPermissionToml::Access(FileSystemAccessMode::None) => {
FilesystemPermissionToml::Access(FileSystemAccessMode::Deny) => {
if path.contains("**") {
patterns.push(path.clone());
}
@@ -606,7 +644,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -
FilesystemPermissionToml::Access(_) => {}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
if *access == FileSystemAccessMode::None && subpath.contains("**") {
if *access == FileSystemAccessMode::Deny && subpath.contains("**") {
patterns.push(format!("{path}/{subpath}"));
}
}
+163 -6
View File
@@ -67,6 +67,8 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -80,7 +82,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu
},
ConfigOverrides {
cwd: Some(cwd.clone()),
zsh_path: Some(zsh_path.clone()),
default_zsh_path: Some(AbsolutePathBuf::try_from(zsh_path.clone())?),
main_execve_wrapper_exe: Some(execve_wrapper),
..Default::default()
},
@@ -238,6 +240,157 @@ fn network_toml_overlays_unix_socket_permissions_by_path() {
);
}
#[test]
fn permissions_profiles_resolve_extends_parent_first_with_child_overrides() {
let permissions = toml::from_str::<PermissionsToml>(
r#"
[base]
description = "Base profile"
[base.filesystem]
glob_scan_max_depth = 1
"/tmp/base" = "read"
"/tmp/shared" = "read"
[base.filesystem.":project_roots"]
"**/*.env" = "deny"
docs = "read"
[base.network]
enabled = true
[base.network.domains]
"base.example.com" = "allow"
"SHARED.EXAMPLE.COM." = "deny"
[base.network.unix_sockets]
"/tmp/base.sock" = "allow"
[child]
extends = "base"
[child.filesystem]
glob_scan_max_depth = 3
"/tmp/shared" = "write"
[child.filesystem.":project_roots"]
docs = "write"
src = "read"
[child.network]
enabled = false
allow_local_binding = true
[child.network.domains]
"child.example.com" = "allow"
"shared.example.com" = "allow"
[child.network.unix_sockets]
"/tmp/child.sock" = "allow"
"#,
)
.expect("permissions should deserialize");
let resolved = permissions
.resolve_profile("child", |_| None)
.expect("child profile should resolve");
let expected_profile = toml::from_str::<PermissionProfileToml>(
r#"
extends = "base"
[filesystem]
glob_scan_max_depth = 3
"/tmp/base" = "read"
"/tmp/shared" = "write"
[filesystem.":project_roots"]
"**/*.env" = "deny"
docs = "write"
src = "read"
[network]
enabled = false
allow_local_binding = true
[network.domains]
"base.example.com" = "allow"
"child.example.com" = "allow"
"shared.example.com" = "allow"
[network.unix_sockets]
"/tmp/base.sock" = "allow"
"/tmp/child.sock" = "allow"
"#,
)
.expect("expected profile should deserialize");
assert_eq!(resolved.profile, expected_profile);
assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]);
}
#[test]
fn permissions_profiles_reject_undefined_extends_parent() {
let permissions = toml::from_str::<PermissionsToml>(
r#"
[child]
extends = "base"
"#,
)
.expect("permissions should deserialize");
let err = permissions
.resolve_profile("child", |_| None)
.expect_err("missing parent should be rejected");
assert_eq!(
err.to_string(),
"permissions profile `child` extends undefined profile `base`"
);
}
#[test]
fn permissions_profiles_reject_unsupported_builtin_extends_parent() {
let permissions = toml::from_str::<PermissionsToml>(
r#"
[child]
extends = ":danger-full-access"
"#,
)
.expect("permissions should deserialize");
let err = permissions
.resolve_profile("child", |_| None)
.expect_err("unsupported built-in parent should be rejected");
assert_eq!(
err.to_string(),
"permissions profile `child` cannot extend unsupported built-in profile `:danger-full-access`"
);
}
#[test]
fn permissions_profiles_reject_extends_cycles() {
let permissions = toml::from_str::<PermissionsToml>(
r#"
[alpha]
extends = "beta"
[beta]
extends = "alpha"
"#,
)
.expect("permissions should deserialize");
let err = permissions
.resolve_profile("alpha", |_| None)
.expect_err("cycle should be rejected");
assert_eq!(
err.to_string(),
"permissions profile inheritance cycle detected: alpha -> beta -> alpha"
);
}
#[test]
fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() {
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {
@@ -285,6 +438,8 @@ fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std:
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: Some(WorkspaceRootsToml {
entries: BTreeMap::from([
("backend".to_string(), true),
@@ -326,7 +481,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths
(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
("**/*.env".to_string(), FileSystemAccessMode::None),
("**/*.env".to_string(), FileSystemAccessMode::Deny),
("docs/**".to_string(), FileSystemAccessMode::Read),
("src/**/*.rs".to_string(), FileSystemAccessMode::Write),
])),
@@ -340,7 +495,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths
"/tmp/**/*.log".to_string(),
":workspace_roots/src/**/*.rs".to_string()
],
"`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
"`deny` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
);
}
@@ -351,8 +506,8 @@ fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() {
entries: BTreeMap::from([(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
("**/*.env".to_string(), FileSystemAccessMode::None),
("*.pem".to_string(), FileSystemAccessMode::None),
("**/*.env".to_string(), FileSystemAccessMode::Deny),
("*.pem".to_string(), FileSystemAccessMode::Deny),
])),
)]),
};
@@ -394,6 +549,8 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()>
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -435,7 +592,7 @@ fn read_write_glob_patterns_still_reject_non_subpath_globs() {
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("filesystem glob path `src/**/*.rs` only supports `none` access"),
.contains("filesystem glob path `src/**/*.rs` only supports `deny` access"),
"{err}"
);
}
@@ -40,6 +40,19 @@ pub(crate) enum ResolvedPermissionProfile {
Named(NamedPermissionProfile),
}
/// Trusted snapshot of a resolved permission profile.
///
/// This is a bridge for already-resolved session/config state. It keeps the
/// concrete `PermissionProfile`, optional active profile id, and
/// profile-defined workspace roots together so `Permissions` can validate and
/// install them atomically. It is not a resolver: callers that are handling
/// user-selected profile ids should resolve those ids through config instead
/// of constructing this type directly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionProfileSnapshot {
resolved_permission_profile: ResolvedPermissionProfile,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LegacyPermissionProfile {
permission_profile: PermissionProfile,
@@ -124,6 +137,93 @@ impl ResolvedPermissionProfile {
}
}
impl PermissionProfileSnapshot {
/// Create a snapshot with no active profile id.
///
/// Prefer this only for legacy data or local overrides that genuinely do
/// not have a named/built-in profile identity. Using this for a built-in or
/// named profile will intentionally clear the active profile metadata.
pub fn legacy(permission_profile: PermissionProfile) -> Self {
Self {
resolved_permission_profile: ResolvedPermissionProfile::legacy(permission_profile),
}
}
/// Create a snapshot for a known active profile id.
///
/// Use this only after a trusted caller has already resolved the active id
/// to the supplied concrete `PermissionProfile`. This constructor does not
/// verify that the id and profile match; `Permissions` will still enforce
/// configured permission constraints when the snapshot is installed.
pub fn active(
permission_profile: PermissionProfile,
active_permission_profile: ActivePermissionProfile,
) -> Self {
Self::active_with_profile_workspace_roots(
permission_profile,
active_permission_profile,
Vec::new(),
)
}
/// Create a snapshot for a known active profile id with profile roots.
///
/// As with `active`, the caller is responsible for passing the concrete
/// profile and active id that were resolved together. Use this variant when
/// the selected profile declared workspace roots that should remain
/// distinct from turn-scoped runtime workspace roots.
pub fn active_with_profile_workspace_roots(
permission_profile: PermissionProfile,
active_permission_profile: ActivePermissionProfile,
profile_workspace_roots: Vec<AbsolutePathBuf>,
) -> Self {
Self {
resolved_permission_profile: ResolvedPermissionProfile::from_active_profile(
permission_profile,
Some(active_permission_profile),
profile_workspace_roots,
),
}
}
/// Reconstruct a trusted snapshot from session state.
///
/// This is intended for session responses emitted by core, where the
/// concrete profile and active profile id were captured together. Avoid
/// using this as a shortcut for arbitrary user input because mismatched
/// arguments can still misrepresent the active profile identity.
pub fn from_session_snapshot(
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
) -> Self {
match active_permission_profile {
Some(active_permission_profile) => {
Self::active(permission_profile, active_permission_profile)
}
None => Self::legacy(permission_profile),
}
}
/// Borrow the concrete permission profile captured in this snapshot.
pub fn permission_profile(&self) -> &PermissionProfile {
self.resolved_permission_profile.permission_profile()
}
/// Return the active profile id captured in this snapshot, if any.
pub fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
self.resolved_permission_profile.active_permission_profile()
}
/// Borrow profile-declared workspace roots captured in this snapshot.
pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] {
self.resolved_permission_profile.profile_workspace_roots()
}
pub(crate) fn into_resolved_permission_profile(self) -> ResolvedPermissionProfile {
self.resolved_permission_profile
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PermissionProfileState {
resolved_permission_profile: Constrained<ResolvedPermissionProfile>,
@@ -199,17 +299,11 @@ impl PermissionProfileState {
.set(ResolvedPermissionProfile::legacy(permission_profile))
}
pub(crate) fn set_active_permission_profile(
pub(crate) fn set_permission_profile_snapshot(
&mut self,
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
profile_workspace_roots: Vec<AbsolutePathBuf>,
snapshot: PermissionProfileSnapshot,
) -> ConstraintResult<()> {
let candidate = ResolvedPermissionProfile::from_active_profile(
permission_profile,
active_permission_profile,
profile_workspace_roots,
);
self.resolved_permission_profile.set(candidate)
self.resolved_permission_profile
.set(snapshot.into_resolved_permission_profile())
}
}
+9 -9
View File
@@ -35,7 +35,7 @@ use codex_login::CodexAuth;
use codex_login::default_client::originator;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
@@ -198,11 +198,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
config.codex_linux_sandbox_exe.clone(),
)?;
let environment_manager =
EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?;
EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths))
.await?;
list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
force_refetch,
&environment_manager,
Arc::new(environment_manager),
)
.await
}
@@ -210,7 +211,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config: &Config,
force_refetch: bool,
environment_manager: &EnvironmentManager,
environment_manager: Arc<EnvironmentManager>,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
@@ -261,10 +262,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
let (tx_event, rx_event) = unbounded();
drop(rx_event);
let environment = environment_manager
.default_environment()
.unwrap_or_else(|| environment_manager.local_environment());
let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
@@ -273,10 +270,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
INITIAL_SUBMIT_ID.to_owned(),
tx_event,
PermissionProfile::default(),
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
// Connector discovery is threadless. Use an actually configured env if
// one exists, but do not reintroduce the old hidden-local fallback.
McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
ToolPluginProvenance::default(),
auth.as_ref(),
@@ -14,9 +14,17 @@ impl ApprovedCommandPrefixSaved {
}
impl ContextualUserFragment for ApprovedCommandPrefixSaved {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
format!("Approved command prefix saved:\n{}", self.prefixes)
+11 -3
View File
@@ -18,9 +18,17 @@ impl AppsInstructions {
}
impl ContextualUserFragment for AppsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = APPS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = APPS_INSTRUCTIONS_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(APPS_INSTRUCTIONS_OPEN_TAG, APPS_INSTRUCTIONS_CLOSE_TAG)
}
fn body(&self) -> String {
format!(
@@ -22,9 +22,20 @@ impl AvailablePluginsInstructions {
}
impl ContextualUserFragment for AvailablePluginsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = PLUGINS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = PLUGINS_INSTRUCTIONS_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
PLUGINS_INSTRUCTIONS_OPEN_TAG,
PLUGINS_INSTRUCTIONS_CLOSE_TAG,
)
}
fn body(&self) -> String {
let mut lines = vec![
@@ -21,9 +21,17 @@ impl From<AvailableSkills> for AvailableSkillsInstructions {
}
impl ContextualUserFragment for AvailableSkillsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = SKILLS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG)
}
fn body(&self) -> String {
render_available_skills_body(&self.skill_root_lines, &self.skill_lines)
@@ -22,9 +22,17 @@ impl CollaborationModeInstructions {
}
impl ContextualUserFragment for CollaborationModeInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = COLLABORATION_MODE_OPEN_TAG;
const END_MARKER: &'static str = COLLABORATION_MODE_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(COLLABORATION_MODE_OPEN_TAG, COLLABORATION_MODE_CLOSE_TAG)
}
fn body(&self) -> String {
self.instructions.clone()
@@ -1,8 +1,11 @@
use super::*;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::context::SubagentNotification;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
#[test]
fn detects_environment_context_fragment() {
@@ -28,16 +31,25 @@ fn detects_subagent_notification_fragment_case_insensitively() {
#[test]
fn detects_goal_context_fragment() {
let text = GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
}
.render();
let text = GoalContext::new("Continue working toward the active thread goal.").render();
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text
}));
}
#[test]
fn contextual_user_fragment_is_dyn_compatible() {
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext::new(
"Continue working toward the active thread goal.",
));
assert_eq!(
fragment.render(),
"<goal_context>\nContinue working toward the active thread goal.\n</goal_context>"
);
}
#[test]
fn ignores_regular_user_text() {
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
@@ -269,9 +269,20 @@ impl EnvironmentContext {
}
impl ContextualUserFragment for EnvironmentContext {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
const END_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG,
codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG,
)
}
fn body(&self) -> String {
let mut lines = Vec::new();
+33 -20
View File
@@ -38,37 +38,34 @@ impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationPro
/// in which case the default helpers render only the body and never match
/// arbitrary text.
pub trait ContextualUserFragment {
const ROLE: &'static str;
const START_MARKER: &'static str;
const END_MARKER: &'static str;
fn role() -> &'static str
where
Self: Sized;
fn markers(&self) -> (&'static str, &'static str);
fn body(&self) -> String;
fn type_markers() -> (&'static str, &'static str)
where
Self: Sized;
fn matches_text(text: &str) -> bool
where
Self: Sized,
{
if Self::START_MARKER.is_empty() || Self::END_MARKER.is_empty() {
return false;
}
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..Self::START_MARKER.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::START_MARKER));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(Self::END_MARKER.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::END_MARKER));
starts_with_marker && ends_with_marker
let (start_marker, end_marker) = Self::type_markers();
matches_marked_text(start_marker, end_marker, text)
}
fn render(&self) -> String {
if Self::START_MARKER.is_empty() && Self::END_MARKER.is_empty() {
return self.body();
let (start_marker, end_marker) = self.markers();
let body = self.body();
if start_marker.is_empty() && end_marker.is_empty() {
return body;
}
format!("{}{}{}", Self::START_MARKER, self.body(), Self::END_MARKER)
format!("{start_marker}{body}{end_marker}")
}
fn into(self) -> ResponseItem
@@ -77,7 +74,7 @@ pub trait ContextualUserFragment {
{
ResponseItem::Message {
id: None,
role: Self::ROLE.to_string(),
role: Self::role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
@@ -85,3 +82,19 @@ pub trait ContextualUserFragment {
}
}
}
fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
if start_marker.is_empty() || end_marker.is_empty() {
return false;
}
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..start_marker.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(end_marker.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker));
starts_with_marker && ends_with_marker
}
+36 -5
View File
@@ -1,16 +1,47 @@
//! Hidden user-context fragment for runtime-owned goal steering prompts.
use super::ContextualUserFragment;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
/// Hidden runtime-owned goal steering context injected into model input.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct GoalContext {
pub(crate) prompt: String,
pub struct GoalContext {
prompt: String,
}
impl GoalContext {
/// Creates goal context around an already-rendered steering prompt.
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
}
}
/// Converts the registered fragment into an active-turn injectable item.
pub fn into_response_input_item(self) -> ResponseInputItem {
ResponseInputItem::Message {
role: <Self as ContextualUserFragment>::role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
}
}
}
impl ContextualUserFragment for GoalContext {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<goal_context>";
const END_MARKER: &'static str = "</goal_context>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<goal_context>", "</goal_context>")
}
fn body(&self) -> String {
format!("\n{}\n", self.prompt)
@@ -4,9 +4,17 @@ use super::ContextualUserFragment;
pub(crate) struct GuardianFollowupReviewReminder;
impl ContextualUserFragment for GuardianFollowupReviewReminder {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
concat!(
@@ -12,9 +12,17 @@ impl HookAdditionalContext {
}
impl ContextualUserFragment for HookAdditionalContext {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
self.text.clone()
@@ -17,9 +17,17 @@ impl ImageGenerationInstructions {
}
impl ContextualUserFragment for ImageGenerationInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
format!(
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyApplyPatchExecCommandWarning;
impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn matches_text(text: &str) -> bool {
let trimmed = text.trim();
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyModelMismatchWarning;
impl ContextualUserFragment for LegacyModelMismatchWarning {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn matches_text(text: &str) -> bool {
text.trim().starts_with(
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyUnifiedExecProcessLimitWarning;
impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn matches_text(text: &str) -> bool {
text.trim().starts_with(
+1 -1
View File
@@ -40,7 +40,7 @@ pub(crate) use environment_context::EnvironmentContext;
pub use fragment::ContextualUserFragment;
pub(crate) use fragment::FragmentRegistration;
pub(crate) use fragment::FragmentRegistrationProxy;
pub(crate) use goal_context::GoalContext;
pub use goal_context::GoalContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
@@ -14,9 +14,17 @@ impl ModelSwitchInstructions {
}
impl ContextualUserFragment for ModelSwitchInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<model_switch>";
const END_MARKER: &'static str = "</model_switch>";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<model_switch>", "</model_switch>")
}
fn body(&self) -> String {
format!(
@@ -18,9 +18,17 @@ impl NetworkRuleSaved {
}
impl ContextualUserFragment for NetworkRuleSaved {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
let (action, list_name) = match self.action {
@@ -8,7 +8,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::WritableRoot;
use codex_utils_template::Template;
use std::path::Path;
@@ -85,27 +84,6 @@ impl PermissionsInstructions {
)
}
/// Builds permissions instructions from a legacy sandbox policy.
pub fn from_policy(
sandbox_policy: &SandboxPolicy,
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
exec_policy: &Policy,
cwd: &Path,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
Self::from_permission_profile(
&PermissionProfile::from_legacy_sandbox_policy(sandbox_policy),
approval_policy,
approvals_reviewer,
exec_policy,
cwd,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
)
}
fn from_permissions_with_network(
sandbox_mode: SandboxMode,
network_access: NetworkAccess,
@@ -167,9 +145,17 @@ fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAc
}
impl ContextualUserFragment for PermissionsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<permissions instructions>";
const END_MARKER: &'static str = "</permissions instructions>";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<permissions instructions>", "</permissions instructions>")
}
fn body(&self) -> String {
self.text.clone()
@@ -53,29 +53,6 @@ fn builds_permissions_with_network_access_override() {
);
}
#[test]
fn builds_permissions_from_policy() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let instructions = PermissionsInstructions::from_policy(
&policy,
AskForApproval::UnlessTrusted,
ApprovalsReviewer::User,
&Policy::empty(),
&PathBuf::from("/tmp"),
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
let text = instructions.body();
assert!(text.contains("Network access is enabled."));
assert!(text.contains("`approval_policy` is `unless-trusted`"));
}
#[test]
fn builds_permissions_from_profile() {
let cwd = PathBuf::from("/tmp");
@@ -12,9 +12,17 @@ impl PersonalitySpecInstructions {
}
impl ContextualUserFragment for PersonalitySpecInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<personality_spec>";
const END_MARKER: &'static str = "</personality_spec>";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<personality_spec>", "</personality_spec>")
}
fn body(&self) -> String {
format!(
@@ -12,9 +12,17 @@ impl PluginInstructions {
}
impl ContextualUserFragment for PluginInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
self.text.clone()
@@ -18,9 +18,20 @@ impl RealtimeEndInstructions {
}
impl ContextualUserFragment for RealtimeEndInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
REALTIME_CONVERSATION_OPEN_TAG,
REALTIME_CONVERSATION_CLOSE_TAG,
)
}
fn body(&self) -> String {
format!(
@@ -8,9 +8,20 @@ const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtim
pub(crate) struct RealtimeStartInstructions;
impl ContextualUserFragment for RealtimeStartInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
REALTIME_CONVERSATION_OPEN_TAG,
REALTIME_CONVERSATION_CLOSE_TAG,
)
}
fn body(&self) -> String {
format!("\n{}\n", REALTIME_START_INSTRUCTIONS.trim())
@@ -16,9 +16,20 @@ impl RealtimeStartWithInstructions {
}
impl ContextualUserFragment for RealtimeStartWithInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
REALTIME_CONVERSATION_OPEN_TAG,
REALTIME_CONVERSATION_CLOSE_TAG,
)
}
fn body(&self) -> String {
format!("\n{}\n", self.instructions)
@@ -20,9 +20,17 @@ impl From<&SkillInjection> for SkillInstructions {
}
impl ContextualUserFragment for SkillInstructions {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<skill>";
const END_MARKER: &'static str = "</skill>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<skill>", "</skill>")
}
fn body(&self) -> String {
format!(
@@ -18,9 +18,17 @@ impl SubagentNotification {
}
impl ContextualUserFragment for SubagentNotification {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<subagent_notification>";
const END_MARKER: &'static str = "</subagent_notification>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<subagent_notification>", "</subagent_notification>")
}
fn body(&self) -> String {
format!(
+11 -3
View File
@@ -17,9 +17,17 @@ impl TurnAborted {
}
impl ContextualUserFragment for TurnAborted {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<turn_aborted>";
const END_MARKER: &'static str = "</turn_aborted>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<turn_aborted>", "</turn_aborted>")
}
fn body(&self) -> String {
format!("\n{}\n", self.guidance)
+11 -3
View File
@@ -7,9 +7,17 @@ pub(crate) struct UserInstructions {
}
impl ContextualUserFragment for UserInstructions {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "# AGENTS.md instructions for ";
const END_MARKER: &'static str = "</INSTRUCTIONS>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("# AGENTS.md instructions for ", "</INSTRUCTIONS>")
}
fn body(&self) -> String {
format!("{}\n\n<INSTRUCTIONS>\n{}\n", self.directory, self.text)
@@ -27,9 +27,17 @@ impl UserShellCommand {
}
impl ContextualUserFragment for UserShellCommand {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<user_shell_command>";
const END_MARKER: &'static str = "</user_shell_command>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<user_shell_command>", "</user_shell_command>")
}
fn body(&self) -> String {
format!(
+46 -10
View File
@@ -126,6 +126,11 @@ impl ContextManager {
&self.items
}
/// Returns raw items in the history and consumes the snapshot.
pub(crate) fn into_raw_items(self) -> Vec<ResponseItem> {
self.items
}
pub(crate) fn history_version(&self) -> u64 {
self.history_version
}
@@ -504,6 +509,10 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize {
.saturating_sub(650)
}
fn estimate_encrypted_function_output_length(encoded_len: usize) -> usize {
encoded_len.saturating_mul(9).div_ceil(16)
}
fn estimate_item_token_count(item: &ResponseItem) -> i64 {
let model_visible_bytes = estimate_response_item_model_visible_bytes(item);
approx_tokens_from_byte_count_i64(model_visible_bytes)
@@ -547,16 +556,18 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) ->
let raw = serde_json::to_string(item)
.map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX))
.unwrap_or_default();
let (payload_bytes, replacement_bytes) = image_data_url_estimate_adjustment(item);
if payload_bytes == 0 || replacement_bytes == 0 {
raw
} else {
// Replace raw base64 payload bytes with a per-image estimate.
// We intentionally preserve the data URL prefix and JSON
// wrapper bytes already included in `raw`.
raw.saturating_sub(payload_bytes)
.saturating_add(replacement_bytes)
}
let (image_payload_bytes, image_replacement_bytes) =
image_data_url_estimate_adjustment(item);
let (encrypted_payload_bytes, encrypted_replacement_bytes) =
encrypted_function_output_estimate_adjustment(item);
// Replace raw base64 payload bytes with a per-image estimate.
// We intentionally preserve the data URL prefix and JSON
// wrapper bytes already included in `raw`.
let raw = raw
.saturating_sub(image_payload_bytes)
.saturating_add(image_replacement_bytes);
raw.saturating_sub(encrypted_payload_bytes)
.saturating_add(encrypted_replacement_bytes)
}
}
}
@@ -678,6 +689,31 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
(payload_bytes, replacement_bytes)
}
fn encrypted_function_output_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
let ResponseItem::FunctionCallOutput { output, .. } = item else {
return (0, 0);
};
let FunctionCallOutputBody::ContentItems(items) = &output.body else {
return (0, 0);
};
items.iter().fold((0i64, 0i64), |acc, item| {
let FunctionCallOutputContentItem::EncryptedContent { encrypted_content } = item else {
return acc;
};
let payload_bytes = acc
.0
.saturating_add(i64::try_from(encrypted_content.len()).unwrap_or(i64::MAX));
let replacement_bytes = acc.1.saturating_add(
i64::try_from(estimate_encrypted_function_output_length(
encrypted_content.len(),
))
.unwrap_or(i64::MAX),
);
(payload_bytes, replacement_bytes)
})
}
fn is_model_generated_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, .. } => role == "assistant",
@@ -2,7 +2,6 @@ use super::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
@@ -121,7 +120,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
fn reference_context_item() -> TurnContextItem {
TurnContextItem {
turn_id: Some("reference-turn".to_string()),
trace_id: None,
cwd: PathBuf::from("/tmp/reference-cwd"),
current_date: Some("2026-03-23".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
@@ -135,11 +133,7 @@ fn reference_context_item() -> TurnContextItem {
collaboration_mode: None,
realtime_active: Some(false),
effort: None,
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}
}
@@ -1760,6 +1754,26 @@ fn non_base64_image_urls_are_unchanged() {
);
}
#[test]
fn encrypted_function_output_uses_plaintext_byte_estimate() {
let encrypted_content = "A".repeat(1_868);
let item = ResponseItem::FunctionCallOutput {
call_id: "call-encrypted".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: encrypted_content.clone(),
},
]),
};
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
let estimated = estimate_response_item_model_visible_bytes(&item);
let expected = raw_len - encrypted_content.len() as i64
+ estimate_encrypted_function_output_length(encrypted_content.len()) as i64;
assert_eq!(estimated, expected);
}
#[test]
fn data_url_without_base64_marker_is_unchanged() {
let item = ResponseItem::Message {
+6 -5
View File
@@ -106,7 +106,7 @@ mod tests {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
test_runtime_paths(),
Some(test_runtime_paths()),
)
.await;
@@ -132,9 +132,10 @@ url = "ws://127.0.0.1:8765"
)
.expect("write environments.toml");
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::from_codex_home(temp_dir.path(), test_runtime_paths())
.await
.expect("environment manager");
let manager =
EnvironmentManager::from_codex_home(temp_dir.path(), Some(test_runtime_paths()))
.await
.expect("environment manager");
assert_eq!(
default_thread_environment_selections(&manager, &cwd),
@@ -154,7 +155,7 @@ url = "ws://127.0.0.1:8765"
#[tokio::test]
async fn default_thread_environment_selections_empty_when_default_disabled() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
let manager = EnvironmentManager::without_environments();
assert_eq!(
default_thread_environment_selections(&manager, &cwd),
+2 -1
View File
@@ -90,9 +90,10 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
text_elements: Vec::new(),
});
}
ContentItem::InputImage { image_url, .. } => {
ContentItem::InputImage { image_url, detail } => {
content.push(UserInput::Image {
image_url: image_url.clone(),
detail: *detail,
});
}
ContentItem::OutputText { text } => {
+17 -8
View File
@@ -48,8 +48,14 @@ fn parses_user_message_with_text_and_two_images() {
text: "Hello world".to_string(),
text_elements: Vec::new(),
},
UserInput::Image { image_url: img1 },
UserInput::Image { image_url: img2 },
UserInput::Image {
image_url: img1,
detail: Some(DEFAULT_IMAGE_DETAIL),
},
UserInput::Image {
image_url: img2,
detail: Some(DEFAULT_IMAGE_DETAIL),
},
];
assert_eq!(user.content, expected_content);
}
@@ -87,7 +93,10 @@ fn skips_local_image_label_text() {
match turn_item {
TurnItem::UserMessage(user) => {
let expected_content = vec![
UserInput::Image { image_url },
UserInput::Image {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
},
UserInput::Text {
text: user_text,
text_elements: Vec::new(),
@@ -165,7 +174,10 @@ fn skips_unnamed_image_label_text() {
match turn_item {
TurnItem::UserMessage(user) => {
let expected_content = vec![
UserInput::Image { image_url },
UserInput::Image {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
},
UserInput::Text {
text: user_text,
text_elements: Vec::new(),
@@ -310,10 +322,7 @@ fn goal_context_does_not_parse_as_visible_turn_item() {
id: Some("msg-1".to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
}
.render(),
text: GoalContext::new("Continue working toward the active thread goal.").render(),
}],
phase: None,
};
+24 -10
View File
@@ -433,10 +433,10 @@ pub(crate) async fn execute_exec_request(
expiration,
capture_policy,
sandbox,
windows_sandbox_policy_cwd: _,
windows_sandbox_policy_cwd,
windows_sandbox_level,
windows_sandbox_private_desktop,
permission_profile: _,
permission_profile,
file_system_sandbox_policy: _,
network_sandbox_policy,
windows_sandbox_filesystem_overrides,
@@ -465,6 +465,8 @@ pub(crate) async fn execute_exec_request(
after_spawn,
sandbox,
&sandbox_policy,
&permission_profile,
&windows_sandbox_policy_cwd,
windows_sandbox_filesystem_overrides.as_ref(),
)
.await;
@@ -472,6 +474,7 @@ pub(crate) async fn execute_exec_request(
finalize_exec_result(raw_output_result, sandbox, duration)
}
#[allow(clippy::too_many_arguments)]
async fn get_raw_output_result(
params: ExecParams,
network_sandbox_policy: NetworkSandboxPolicy,
@@ -479,14 +482,22 @@ async fn get_raw_output_result(
after_spawn: Option<Box<dyn FnOnce() + Send>>,
#[cfg_attr(not(windows), allow(unused_variables))] sandbox: SandboxType,
#[cfg_attr(not(windows), allow(unused_variables))] sandbox_policy: &SandboxPolicy,
#[cfg_attr(not(windows), allow(unused_variables))] permission_profile: &PermissionProfile,
#[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_policy_cwd: &AbsolutePathBuf,
#[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_filesystem_overrides: Option<
&WindowsSandboxFilesystemOverrides,
>,
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken {
return exec_windows_sandbox(params, sandbox_policy, windows_sandbox_filesystem_overrides)
.await;
return exec_windows_sandbox(
params,
sandbox_policy,
permission_profile,
windows_sandbox_policy_cwd,
windows_sandbox_filesystem_overrides,
)
.await;
}
exec(params, network_sandbox_policy, stdout_stream, after_spawn).await
@@ -562,10 +573,12 @@ fn record_windows_sandbox_spawn_failure(
async fn exec_windows_sandbox(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
windows_sandbox_policy_cwd: &AbsolutePathBuf,
windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>,
) -> Result<RawExecToolCallOutput> {
use crate::config::find_codex_home;
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
use codex_windows_sandbox::run_windows_sandbox_capture_for_permission_profile_elevated;
use codex_windows_sandbox::run_windows_sandbox_capture_with_filesystem_overrides;
let ExecParams {
@@ -596,7 +609,8 @@ async fn exec_windows_sandbox(
"failed to serialize Windows sandbox policy: {err}"
)))
})?;
let sandbox_cwd = cwd.clone();
let sandbox_cwd = windows_sandbox_policy_cwd.clone();
let permission_profile = permission_profile.clone();
let codex_home = find_codex_home().map_err(|err| {
CodexErr::Io(io::Error::other(format!(
"windows sandbox: failed to resolve codex_home: {err}"
@@ -620,10 +634,10 @@ async fn exec_windows_sandbox(
.and_then(|overrides| overrides.write_roots_override.clone());
let spawn_res = tokio::task::spawn_blocking(move || {
if use_elevated {
run_windows_sandbox_capture_elevated(
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
policy_json_or_preset: policy_str.as_str(),
sandbox_policy_cwd: &sandbox_cwd,
run_windows_sandbox_capture_for_permission_profile_elevated(
codex_windows_sandbox::ElevatedSandboxProfileCaptureRequest {
permission_profile: &permission_profile,
permission_profile_cwd: &sandbox_cwd,
codex_home: codex_home.as_ref(),
command,
cwd: &cwd,
+44 -72
View File
@@ -23,7 +23,6 @@ use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
@@ -130,10 +129,6 @@ fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::external_sandbox()
}
fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
}
async fn test_config() -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::without_managed_config_for_tests()
@@ -669,7 +664,7 @@ async fn evaluates_bash_lc_inner_commands() {
"rm -rf /some/important/folder".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -765,7 +760,7 @@ async fn evaluates_heredoc_script_against_prefix_rules() {
policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()),
command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -789,7 +784,7 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() {
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -813,7 +808,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec![
@@ -841,7 +836,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches(
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["python3".to_string()]),
@@ -866,7 +861,7 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() {
"PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -897,7 +892,7 @@ EOF"#
.to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -931,7 +926,7 @@ EOF"#
.to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -971,7 +966,7 @@ prefix_rule(
"/some/important/folder".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -990,7 +985,7 @@ async fn exec_approval_requirement_prefers_execpolicy_match() {
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1018,7 +1013,7 @@ prefix_rule(pattern=["git"], decision="allow")
policy_src: Some(policy_src),
command: vec![git_path, "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1052,7 +1047,7 @@ prefix_rule(pattern=["git"], decision="prompt")
policy_src: Some(policy_src),
command: vec![disallowed_git_path.clone(), "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1079,7 +1074,7 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() {
"cargo-insta".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
@@ -1102,7 +1097,7 @@ async fn exec_approval_requirement_respects_approval_policy() {
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
command: vec!["rm".to_string()],
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1130,9 +1125,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
request_permissions: true,
mcp_elicitations: true,
}),
permission_profile: &permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: &PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
@@ -1175,9 +1168,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() {
&command,
UnmatchedCommandContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: &permission_profile_from_sandbox_policy(
&SandboxPolicy::new_workspace_write_policy(),
),
permission_profile: &PermissionProfile::workspace_write(),
file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
@@ -1258,7 +1249,7 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und
"touch requested-dir/requested-but-unused.txt".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
prefix_rule: None,
@@ -1281,7 +1272,7 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re
policy_src: None,
command: vec!["echo".to_string(), "hello".to_string()],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -1311,7 +1302,7 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s
request_permissions: true,
mcp_elicitations: true,
}),
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -1337,7 +1328,7 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
request_permissions: true,
mcp_elicitations: true,
}),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -1373,9 +1364,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision()
request_permissions: true,
mcp_elicitations: true,
}),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
@@ -1413,9 +1402,7 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled(
request_permissions: true,
mcp_elicitations: true,
}),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
@@ -1440,9 +1427,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -1468,9 +1453,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -1500,9 +1483,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -1532,9 +1513,7 @@ async fn request_rule_uses_prefix_rule() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
@@ -1683,7 +1662,7 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol
policy_src: None,
command: command.clone(),
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1703,7 +1682,7 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1727,7 +1706,7 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
"cargo build && echo ok".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1757,7 +1736,7 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri
policy_src: Some(policy_src.to_string()),
command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1781,7 +1760,7 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
policy_src: None,
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1805,7 +1784,7 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow()
"print(1)".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1836,7 +1815,7 @@ prefix_rule(pattern=["cat"], decision="allow")
policy_src: Some(policy_src.to_string()),
command: command.clone(),
approval_policy,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1868,7 +1847,7 @@ prefix_rule(pattern=["bash"], decision="allow")
.to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -2034,7 +2013,7 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() {
policy_src: None,
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -2098,9 +2077,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &sneaky_command,
approval_policy: AskForApproval::OnRequest,
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
@@ -2125,9 +2102,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &dangerous_command,
approval_policy: AskForApproval::OnRequest,
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
@@ -2148,9 +2123,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &dangerous_command,
approval_policy: AskForApproval::Never,
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
@@ -2170,8 +2143,8 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
policy_src: None,
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
permission_profile: PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -2195,8 +2168,8 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()),
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
permission_profile: PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -2214,7 +2187,7 @@ struct ExecApprovalRequirementScenario {
policy_src: Option<String>,
command: Vec<String>,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
permission_profile: PermissionProfile,
file_system_sandbox_policy: FileSystemSandboxPolicy,
sandbox_permissions: SandboxPermissions,
prefix_rule: Option<Vec<String>>,
@@ -2238,7 +2211,7 @@ async fn exec_approval_requirement_for_command(
policy_src,
command,
approval_policy,
sandbox_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
@@ -2246,7 +2219,6 @@ async fn exec_approval_requirement_for_command(
let policy = policy_from_src(policy_src.as_deref());
let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy);
ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
@@ -14,7 +14,7 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() {
"echo blocked".to_string(),
],
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -38,7 +38,7 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() {
"echo blocked".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -80,9 +80,7 @@ fn unmatched_safe_powershell_words_are_allowed() {
&command,
UnmatchedCommandContext {
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: &permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile: &PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -111,7 +109,7 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
"Remove-Item test -Force".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
+4 -5
View File
@@ -347,8 +347,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result
];
let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?;
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
let permission_profile = PermissionProfile::Disabled;
let output = process_exec_tool_call(
ExecParams {
command,
@@ -699,7 +698,7 @@ fn windows_restricted_token_rejects_unreadable_split_carveouts() {
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path { path: blocked },
access: codex_protocol::permissions::FileSystemAccessMode::None,
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
},
]);
@@ -847,7 +846,7 @@ fn windows_elevated_supports_unreadable_split_carveouts() {
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&blocked)
.expect("absolute blocked"),
},
access: codex_protocol::permissions::FileSystemAccessMode::None,
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
},
]);
@@ -909,7 +908,7 @@ fn windows_elevated_supports_unreadable_globs() {
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: codex_protocol::permissions::FileSystemAccessMode::None,
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
},
]);
+59
View File
@@ -2,6 +2,7 @@ use codex_exec_server::LOCAL_FS;
use codex_git_utils::GitInfo;
use codex_git_utils::GitSha;
use codex_git_utils::collect_git_info;
use codex_git_utils::get_git_repo_root_with_fs;
use codex_git_utils::get_has_changes;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::recent_commits;
@@ -379,6 +380,50 @@ async fn test_get_has_changes_ignores_repo_fsmonitor_config() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_get_has_changes_ignores_configured_hooks_path() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
let hooks_dir = repo_path.join(".git/hooks-path-test");
let hook_path = hooks_dir.join("post-index-change");
let marker_path = repo_path.join("hook-ran");
fs::create_dir_all(&hooks_dir).expect("create hook dir");
fs::write(
&hook_path,
format!(
"#!/bin/sh\nprintf ran > \"{}\"\n",
marker_path.to_string_lossy()
),
)
.expect("write post-index-change hook");
let mut permissions = fs::metadata(&hook_path)
.expect("read hook metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&hook_path, permissions).expect("mark hook executable");
Command::new("git")
.args([
"config",
"core.hooksPath",
hooks_dir.to_string_lossy().as_ref(),
])
.current_dir(&repo_path)
.output()
.await
.expect("configure hooks path");
fs::write(repo_path.join("test.txt"), "test content").expect("refresh tracked file");
assert_eq!(get_has_changes(&repo_path).await, Some(false));
assert!(
!marker_path.exists(),
"metadata collection should not invoke configured hook directories"
);
}
#[tokio::test]
async fn test_get_git_working_tree_state_clean_repo() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
@@ -482,6 +527,20 @@ async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
);
}
#[tokio::test]
async fn get_git_repo_root_with_fs_detects_gitdir_pointer() {
let tmp = TempDir::new().expect("tempdir");
let proj = tmp.path().join("proj");
let nested = proj.join("nested");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(proj.join(".git"), "gitdir: /tmp/fake-worktree\n").unwrap();
assert_eq!(
get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await,
Some(proj.abs())
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
+190 -85
View File
@@ -5,8 +5,8 @@
//! events, and owns helper hooks used by goal lifecycle behavior.
use crate::StateDbHandle;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::state::ActiveTurn;
@@ -15,21 +15,22 @@ use crate::tasks::RegularTask;
use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME;
use anyhow::Context;
use codex_features::Feature;
use codex_otel::GOAL_BLOCKED_METRIC;
use codex_otel::GOAL_BUDGET_LIMITED_METRIC;
use codex_otel::GOAL_COMPLETED_METRIC;
use codex_otel::GOAL_CREATED_METRIC;
use codex_otel::GOAL_DURATION_SECONDS_METRIC;
use codex_otel::GOAL_RESUMED_METRIC;
use codex_otel::GOAL_TOKEN_COUNT_METRIC;
use codex_otel::GOAL_USAGE_LIMITED_METRIC;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::validate_thread_goal_objective;
use codex_rollout::state_db::reconcile_rollout;
use codex_thread_store::LocalThreadStore;
@@ -153,7 +154,9 @@ pub(crate) enum GoalRuntimeEvent<'a> {
MaybeContinueIfIdle,
TaskAborted {
turn_context: Option<&'a TurnContext>,
reason: TurnAbortReason,
},
UsageLimitReached {
turn_context: &'a TurnContext,
},
ExternalMutationStarting,
ExternalSet {
@@ -331,8 +334,8 @@ impl Session {
/// starts capture the active goal and token baseline, tool completions
/// account usage and may inject budget steering, completion accounting
/// suppresses that steering, external mutations account best-effort before
/// changing state, interrupts pause active goals, thread resumes restore
/// runtime state for already-active goals, explicit maybe-continue events
/// changing state, thread resumes restore runtime state for already-active
/// goals, explicit maybe-continue events
/// start idle goal continuation turns, and continuation turns with no counted
/// autonomous activity suppress the next automatic continuation until
/// user/tool/external activity resets it.
@@ -384,12 +387,13 @@ impl Session {
self.maybe_continue_goal_if_idle_runtime().await;
Ok(())
}),
GoalRuntimeEvent::TaskAborted {
turn_context,
reason,
} => Box::pin(async move {
self.handle_thread_goal_task_abort(turn_context, reason)
.await;
GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move {
self.handle_thread_goal_task_abort(turn_context).await;
Ok(())
}),
GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move {
self.usage_limit_active_thread_goal_for_turn(turn_context)
.await?;
Ok(())
}),
GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move {
@@ -422,6 +426,7 @@ impl Session {
let state_db = self.require_state_db_for_thread_goals().await?;
state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await
.map(|goal| goal.map(protocol_goal_from_state))
@@ -452,20 +457,24 @@ impl Session {
self.account_thread_goal_wall_clock_usage(
&state_db,
codex_state::ThreadGoalAccountingMode::ActiveOnly,
codex_state::GoalAccountingMode::ActiveOnly,
TerminalMetricEmission::Emit,
)
.await?;
let mut replacing_goal = false;
let previous_status;
let goal = if let Some(objective) = objective.as_deref() {
let existing_goal = state_db.get_thread_goal(self.conversation_id).await?;
let existing_goal = state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await?;
previous_status = existing_goal.as_ref().map(|goal| goal.status);
if let Some(existing_goal) = existing_goal.as_ref() {
state_db
.thread_goals()
.update_thread_goal(
self.conversation_id,
codex_state::ThreadGoalUpdate {
codex_state::GoalUpdate {
objective: Some(objective.to_string()),
status: status.map(state_goal_status_from_protocol),
token_budget,
@@ -482,6 +491,7 @@ impl Session {
} else {
replacing_goal = true;
state_db
.thread_goals()
.replace_thread_goal(
self.conversation_id,
objective,
@@ -493,14 +503,18 @@ impl Session {
.await?
}
} else {
let existing_goal = state_db.get_thread_goal(self.conversation_id).await?;
let existing_goal = state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await?;
previous_status = existing_goal.as_ref().map(|goal| goal.status);
let expected_goal_id = existing_goal.map(|goal| goal.goal_id);
let status = status.map(state_goal_status_from_protocol);
state_db
.thread_goals()
.update_thread_goal(
self.conversation_id,
codex_state::ThreadGoalUpdate {
codex_state::GoalUpdate {
objective: None,
status,
token_budget,
@@ -516,6 +530,14 @@ impl Session {
})?
};
if objective.is_some() {
set_thread_preview_from_goal_objective(
&state_db,
self.conversation_id,
goal.objective.as_str(),
)
.await;
}
let goal_status = goal.status;
let goal_id = goal.goal_id.clone();
let previous_status_for_goal = if replacing_goal {
@@ -526,6 +548,7 @@ impl Session {
if replacing_goal {
self.emit_goal_created_metric();
}
self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status);
self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal);
let goal = protocol_goal_from_state(goal);
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
@@ -576,11 +599,12 @@ impl Session {
let state_db = self.require_state_db_for_thread_goals().await?;
self.account_thread_goal_wall_clock_usage(
&state_db,
codex_state::ThreadGoalAccountingMode::ActiveOnly,
codex_state::GoalAccountingMode::ActiveOnly,
TerminalMetricEmission::Emit,
)
.await?;
let goal = state_db
.thread_goals()
.insert_thread_goal(
self.conversation_id,
objective,
@@ -595,6 +619,12 @@ impl Session {
)
})?;
set_thread_preview_from_goal_objective(
&state_db,
self.conversation_id,
goal.objective.as_str(),
)
.await;
let goal_id = goal.goal_id.clone();
self.emit_goal_created_metric();
let goal = protocol_goal_from_state(goal);
@@ -641,6 +671,7 @@ impl Session {
let previous_status = previous_goal
.as_ref()
.and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status));
self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status);
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone()));
let goal_id = goal.goal_id;
@@ -669,7 +700,10 @@ impl Session {
self.clear_stopped_thread_goal_runtime_state().await;
}
}
codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::Complete => {
codex_state::ThreadGoalStatus::Paused
| codex_state::ThreadGoalStatus::Blocked
| codex_state::ThreadGoalStatus::UsageLimited
| codex_state::ThreadGoalStatus::Complete => {
self.clear_stopped_thread_goal_runtime_state().await;
}
}
@@ -723,6 +757,31 @@ impl Session {
.counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]);
}
fn emit_goal_resumed_metric(&self) {
self.services
.session_telemetry
.counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]);
}
fn emit_goal_resumed_metric_if_status_changed(
&self,
previous_status: Option<codex_state::ThreadGoalStatus>,
goal_status: codex_state::ThreadGoalStatus,
) {
if goal_status == codex_state::ThreadGoalStatus::Active
&& matches!(
previous_status,
Some(
codex_state::ThreadGoalStatus::Paused
| codex_state::ThreadGoalStatus::Blocked
| codex_state::ThreadGoalStatus::UsageLimited
)
)
{
self.emit_goal_resumed_metric();
}
}
fn emit_goal_terminal_metrics_if_status_changed(
&self,
previous_status: Option<codex_state::ThreadGoalStatus>,
@@ -733,6 +792,8 @@ impl Session {
}
let counter = match goal.status {
codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC,
codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC,
codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC,
codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC,
codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => {
@@ -760,7 +821,10 @@ impl Session {
state_db: &StateDbHandle,
expected_goal_id: Option<&str>,
) -> anyhow::Result<Option<codex_state::ThreadGoalStatus>> {
let goal = state_db.get_thread_goal(self.conversation_id).await?;
let goal = state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await?;
Ok(goal.and_then(|goal| {
expected_goal_id
.is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id)
@@ -772,7 +836,7 @@ impl Session {
let active = self.active_turn.lock().await;
active
.as_ref()
.and_then(|active_turn| active_turn.tasks.values().next())
.and_then(|active_turn| active_turn.task.as_ref())
.map(|task| Arc::clone(&task.turn_context))
}
@@ -801,7 +865,11 @@ impl Session {
return;
}
};
match state_db.get_thread_goal(self.conversation_id).await {
match state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await
{
Ok(Some(goal))
if matches!(
goal.status,
@@ -848,7 +916,7 @@ impl Session {
async fn clear_reserved_goal_continuation_turn(&self, turn_state: &Arc<Mutex<TurnState>>) {
let mut active_turn_guard = self.active_turn.lock().await;
if let Some(active_turn) = active_turn_guard.as_ref()
&& active_turn.tasks.is_empty()
&& active_turn.task.is_none()
&& Arc::ptr_eq(&active_turn.turn_state, turn_state)
{
*active_turn_guard = None;
@@ -886,11 +954,7 @@ impl Session {
}
}
async fn handle_thread_goal_task_abort(
&self,
turn_context: Option<&TurnContext>,
reason: TurnAbortReason,
) {
async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) {
if let Some(turn_context) = turn_context {
self.take_thread_goal_continuation_turn(&turn_context.sub_id)
.await;
@@ -913,12 +977,6 @@ impl Session {
accounting.turn = None;
}
}
if reason == TurnAbortReason::Interrupted
&& let Err(err) = self.pause_active_thread_goal_for_interrupt().await
{
tracing::warn!("failed to pause active thread goal after interrupt: {err}");
}
}
async fn account_thread_goal_progress(
@@ -963,11 +1021,12 @@ impl Session {
.current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref())
.await?;
let outcome = state_db
.thread_goals()
.account_thread_goal_usage(
self.conversation_id,
time_delta_seconds,
token_delta,
codex_state::ThreadGoalAccountingMode::ActiveOnly,
codex_state::GoalAccountingMode::ActiveOnly,
expected_goal_id.as_deref(),
)
.await?;
@@ -978,13 +1037,15 @@ impl Session {
.is_some_and(|goal_id| reported_goal_id.as_deref() == Some(goal_id))
};
let goal = match outcome {
codex_state::ThreadGoalAccountingOutcome::Updated(goal) => {
codex_state::GoalAccountingOutcome::Updated(goal) => {
let clear_active_goal = match goal.status {
codex_state::ThreadGoalStatus::Active => false,
codex_state::ThreadGoalStatus::BudgetLimited => {
matches!(budget_limit_steering, BudgetLimitSteering::Suppressed)
}
codex_state::ThreadGoalStatus::Paused
| codex_state::ThreadGoalStatus::Blocked
| codex_state::ThreadGoalStatus::UsageLimited
| codex_state::ThreadGoalStatus::Complete => true,
};
{
@@ -1009,7 +1070,7 @@ impl Session {
}
goal
}
codex_state::ThreadGoalAccountingOutcome::Unchanged(_) => return Ok(()),
codex_state::GoalAccountingOutcome::Unchanged(_) => return Ok(()),
};
let should_steer_budget_limit =
matches!(budget_limit_steering, BudgetLimitSteering::Allowed)
@@ -1056,7 +1117,7 @@ impl Session {
};
self.account_thread_goal_wall_clock_usage(
&state_db,
codex_state::ThreadGoalAccountingMode::ActiveOnly,
codex_state::GoalAccountingMode::ActiveOnly,
TerminalMetricEmission::Suppress,
)
.await?;
@@ -1066,7 +1127,7 @@ impl Session {
async fn account_thread_goal_wall_clock_usage(
&self,
state_db: &StateDbHandle,
mode: codex_state::ThreadGoalAccountingMode,
mode: codex_state::GoalAccountingMode,
terminal_metric_emission: TerminalMetricEmission,
) -> anyhow::Result<Option<ThreadGoal>> {
let _accounting_permit = self.goal_runtime.accounting_permit().await?;
@@ -1085,6 +1146,7 @@ impl Session {
.await?;
match state_db
.thread_goals()
.account_thread_goal_usage(
self.conversation_id,
time_delta_seconds,
@@ -1094,7 +1156,7 @@ impl Session {
)
.await?
{
codex_state::ThreadGoalAccountingOutcome::Updated(goal) => {
codex_state::GoalAccountingOutcome::Updated(goal) => {
if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) {
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
}
@@ -1107,7 +1169,7 @@ impl Session {
let goal = protocol_goal_from_state(goal);
Ok(Some(goal))
}
codex_state::ThreadGoalAccountingOutcome::Unchanged(goal) => {
codex_state::GoalAccountingOutcome::Unchanged(goal) => {
{
let mut accounting = self.goal_runtime.accounting.lock().await;
accounting.wall_clock.reset_baseline();
@@ -1122,8 +1184,11 @@ impl Session {
}
}
async fn pause_active_thread_goal_for_interrupt(&self) -> anyhow::Result<()> {
if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) {
async fn usage_limit_active_thread_goal_for_turn(
&self,
turn_context: &TurnContext,
) -> anyhow::Result<()> {
if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) {
return Ok(());
}
@@ -1140,34 +1205,34 @@ impl Session {
let Some(state_db) = self.state_db_for_thread_goals().await? else {
return Ok(());
};
self.account_thread_goal_wall_clock_usage(
&state_db,
codex_state::ThreadGoalAccountingMode::ActiveStatusOnly,
self.account_thread_goal_progress(
turn_context,
BudgetLimitSteering::Suppressed,
TerminalMetricEmission::Emit,
)
.await?;
let previous_status = self
.current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None)
.await?;
let Some(goal) = state_db
.pause_active_thread_goal(self.conversation_id)
.thread_goals()
.usage_limit_active_thread_goal(self.conversation_id)
.await?
else {
return Ok(());
};
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
let goal = protocol_goal_from_state(goal);
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
self.goal_runtime
.accounting
.lock()
.await
.wall_clock
.clear_active_goal();
self.send_event_raw(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
self.clear_active_goal_accounting(turn_context).await;
self.send_event(
turn_context,
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
thread_id: self.conversation_id,
turn_id: None,
turn_id: Some(turn_context.sub_id.clone()),
goal,
}),
})
)
.await;
Ok(())
}
@@ -1192,7 +1257,11 @@ impl Session {
let Some(state_db) = self.state_db_for_thread_goals().await? else {
return Ok(());
};
let Some(goal) = state_db.get_thread_goal(self.conversation_id).await? else {
let Some(goal) = state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await?
else {
self.clear_stopped_thread_goal_runtime_state().await;
return Ok(());
};
@@ -1204,8 +1273,11 @@ impl Session {
.await
.wall_clock
.mark_active_goal(goal.goal_id);
self.emit_goal_resumed_metric();
}
codex_state::ThreadGoalStatus::Paused
| codex_state::ThreadGoalStatus::Blocked
| codex_state::ThreadGoalStatus::UsageLimited
| codex_state::ThreadGoalStatus::BudgetLimited
| codex_state::ThreadGoalStatus::Complete => {
self.clear_stopped_thread_goal_runtime_state().await;
@@ -1237,7 +1309,11 @@ impl Session {
Arc::clone(&active_turn.turn_state)
};
let goal_is_current = match self.state_db_for_thread_goals().await {
Ok(Some(state_db)) => match state_db.get_thread_goal(self.conversation_id).await {
Ok(Some(state_db)) => match state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await
{
Ok(Some(goal))
if goal.goal_id == candidate.goal_id
&& goal.status == codex_state::ThreadGoalStatus::Active =>
@@ -1269,12 +1345,16 @@ impl Session {
.await;
return;
}
{
let mut turn_state = turn_state.lock().await;
for item in candidate.items {
turn_state.push_pending_input(item);
}
}
self.input_queue
.extend_pending_input_for_turn_state(
turn_state.as_ref(),
candidate
.items
.into_iter()
.map(TurnInput::ResponseInputItem)
.collect(),
)
.await;
let turn_context = self
.new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string())
@@ -1284,7 +1364,7 @@ impl Session {
let still_reserved = {
let active_turn = self.active_turn.lock().await;
active_turn.as_ref().is_some_and(|active_turn| {
active_turn.tasks.is_empty() && Arc::ptr_eq(&active_turn.turn_state, &turn_state)
active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, &turn_state)
})
};
if !still_reserved {
@@ -1312,11 +1392,15 @@ impl Session {
tracing::debug!("skipping active goal continuation because a turn is already active");
return None;
}
if self.has_queued_response_items_for_next_turn().await {
if self
.input_queue
.has_queued_response_items_for_next_turn()
.await
{
tracing::debug!("skipping active goal continuation because queued input exists");
return None;
}
if self.has_trigger_turn_mailbox_items().await {
if self.input_queue.has_trigger_turn_mailbox_items().await {
tracing::debug!(
"skipping active goal continuation because trigger-turn mailbox input is pending"
);
@@ -1333,7 +1417,11 @@ impl Session {
return None;
}
};
let goal = match state_db.get_thread_goal(self.conversation_id).await {
let goal = match state_db
.thread_goals()
.get_thread_goal(self.conversation_id)
.await
{
Ok(Some(goal)) => goal,
Ok(None) => {
tracing::debug!("skipping active goal continuation because no goal is set");
@@ -1349,8 +1437,11 @@ impl Session {
return None;
}
if self.active_turn.lock().await.is_some()
|| self.has_queued_response_items_for_next_turn().await
|| self.has_trigger_turn_mailbox_items().await
|| self
.input_queue
.has_queued_response_items_for_next_turn()
.await
|| self.input_queue.has_trigger_turn_mailbox_items().await
{
tracing::debug!("skipping active goal continuation because pending work appeared");
return None;
@@ -1438,6 +1529,21 @@ impl Session {
}
}
async fn set_thread_preview_from_goal_objective(
state_db: &StateDbHandle,
thread_id: ThreadId,
objective: &str,
) {
if let Err(err) = state_db
.set_thread_preview_if_empty(thread_id, objective)
.await
{
tracing::warn!(
"failed to set empty thread preview from goal objective for {thread_id}: {err}"
);
}
}
fn should_ignore_goal_for_mode(mode: ModeKind) -> bool {
mode == ModeKind::Plan
}
@@ -1524,14 +1630,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
}
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
let context = GoalContext { prompt };
ResponseInputItem::Message {
role: <GoalContext as ContextualUserFragment>::ROLE.to_string(),
content: vec![ContentItem::InputText {
text: context.render(),
}],
phase: None,
}
GoalContext::new(prompt).into_response_input_item()
}
pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal {
@@ -1553,6 +1652,8 @@ pub(crate) fn protocol_goal_status_from_state(
match status {
codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active,
codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
}
@@ -1564,6 +1665,8 @@ pub(crate) fn state_goal_status_from_protocol(
match status {
ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active,
ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused,
ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked,
ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited,
ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited,
ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete,
}
@@ -1642,7 +1745,7 @@ mod tests {
}
#[test]
fn continuation_prompt_only_tells_model_to_update_goal_when_complete() {
fn continuation_prompt_allows_complete_and_strict_blocked_updates() {
let prompt = continuation_prompt(&ThreadGoal {
thread_id: ThreadId::new(),
objective: "finish the stack".to_string(),
@@ -1659,9 +1762,11 @@ mod tests {
assert!(prompt.contains("<objective>\nfinish the stack\n</objective>"));
assert!(prompt.contains("Token budget: 10000"));
assert!(prompt.contains("call update_goal with status \"complete\""));
assert!(!prompt.contains(
"explain the blocker or next required input to the user and wait for new input"
));
assert!(prompt.contains("status \"blocked\""));
assert!(prompt.contains("at least three consecutive goal turns"));
assert!(prompt.contains("same blocking condition"));
assert!(prompt.contains("original/user-triggered turn"));
assert!(prompt.contains("truly at an impasse"));
assert!(!prompt.contains("budgetLimited"));
assert!(!prompt.contains("status \"paused\""));
}
+1
View File
@@ -26,6 +26,7 @@ use serde::Serialize;
pub(crate) use approval_request::GuardianApprovalRequest;
pub(crate) use approval_request::GuardianMcpAnnotations;
pub(crate) use approval_request::GuardianNetworkAccessTrigger;
#[cfg(test)]
pub(crate) use approval_request::guardian_approval_request_to_json;
pub(crate) use review::guardian_rejection_message;
pub(crate) use review::guardian_timeout_message;
+2 -1
View File
@@ -635,7 +635,8 @@ pub(super) async fn run_guardian_review_session(
schema: serde_json::Value,
external_cancel: Option<CancellationToken>,
) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) {
let live_network_config = match session.services.network_proxy.as_ref() {
let network_proxy = session.services.network_proxy.load_full();
let live_network_config = match network_proxy.as_ref() {
Some(network_proxy) => match network_proxy.proxy().current_cfg().await {
Ok(config) => Some(config),
Err(err) => {
+58 -17
View File
@@ -7,8 +7,10 @@ use std::time::Duration;
use anyhow::anyhow;
use codex_analytics::GuardianReviewAnalyticsResult;
use codex_analytics::GuardianReviewSessionKind;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
@@ -137,6 +139,7 @@ struct GuardianReviewSessionReuseKey {
model_provider: ModelProviderInfo,
model_context_window: Option<i64>,
model_auto_compact_token_limit: Option<i64>,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope,
model_reasoning_effort: Option<ReasoningEffortConfig>,
model_reasoning_summary: Option<ReasoningSummaryConfig>,
permissions: Permissions,
@@ -161,6 +164,7 @@ impl GuardianReviewSessionReuseKey {
model_provider: spawn_config.model_provider.clone(),
model_context_window: spawn_config.model_context_window,
model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit,
model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope,
model_reasoning_effort: spawn_config.model_reasoning_effort,
model_reasoning_summary: spawn_config.model_reasoning_summary,
permissions: spawn_config.permissions.clone(),
@@ -698,26 +702,36 @@ async fn run_review_on_session(
.total_token_usage()
.await
.unwrap_or_default();
// The legacy SandboxPolicy should match the PermissionProfile.
let guardian_permission_profile = PermissionProfile::read_only();
let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy();
let submit_result = run_before_review_deadline(
deadline,
params.external_cancel.as_ref(),
Box::pin(review_session.codex.submit(Op::UserTurn {
environments: None,
Box::pin(review_session.codex.submit(Op::UserInput {
items: prompt_items.items,
#[allow(deprecated)]
cwd: params.parent_turn.cwd.to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: None,
model: params.model.clone(),
effort: params.reasoning_effort,
summary: Some(params.reasoning_summary),
service_tier: None,
environments: None,
final_output_json_schema: Some(params.schema.clone()),
collaboration_mode: None,
personality: params.personality,
responsesapi_client_metadata: None,
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
#[allow(deprecated)]
cwd: Some(params.parent_turn.cwd.to_path_buf()),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(legacy_sandbox_policy),
permission_profile: Some(guardian_permission_profile),
summary: Some(params.reasoning_summary),
personality: params.personality,
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: params.model.clone(),
reasoning_effort: params.reasoning_effort,
developer_instructions: None,
},
}),
..Default::default()
},
})),
)
.await;
@@ -892,12 +906,11 @@ pub(crate) fn build_guardian_review_session_config(
);
guardian_config.developer_instructions = None;
guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
let sandbox_policy = SandboxPolicy::new_read_only_policy();
guardian_config
.permissions
.set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path())
.set_permission_profile(PermissionProfile::read_only())
.map_err(|err| {
anyhow::anyhow!("guardian review session could not set sandbox policy: {err}")
anyhow::anyhow!("guardian review session could not set permission profile: {err}")
})?;
guardian_config.include_apps_instructions = false;
guardian_config
@@ -1145,6 +1158,34 @@ mod tests {
);
}
#[tokio::test]
async fn guardian_review_session_compact_scope_change_invalidates_cached_session() {
let parent_config = crate::config::test_config().await;
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);
let mut changed_parent_config = parent_config;
changed_parent_config.model_auto_compact_token_limit_scope =
AutoCompactTokenLimitScope::BodyAfterPrefix;
let next_spawn_config = build_guardian_review_session_config(
&changed_parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect("next guardian config");
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config);
assert_ne!(cached_reuse_key, next_reuse_key);
}
#[tokio::test]
async fn guardian_review_session_config_disables_hooks() {
let mut parent_config = crate::config::test_config().await;
+2 -5
View File
@@ -42,7 +42,6 @@ use codex_protocol::protocol::GuardianRiskLevel;
use codex_protocol::protocol::GuardianUserAuthorization;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnCompleteEvent;
use core_test_support::PathBufExt;
use core_test_support::TempDirExt;
@@ -2191,7 +2190,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() {
);
assert_eq!(
guardian_config.permissions.permission_profile(),
&PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy())
&PermissionProfile::read_only()
);
}
@@ -2253,9 +2252,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() {
NetworkProxySpec::from_config_and_constraints(
live_network,
/*requirements*/ None,
&PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
&PermissionProfile::read_only(),
)
.expect("live network proxy spec")
)
+253 -96
View File
@@ -13,12 +13,16 @@ use codex_hooks::PostToolUseRequest;
use codex_hooks::PreToolUseOutcome;
use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::StartHookTarget;
use codex_hooks::StopHookTarget;
use codex_hooks::StopOutcome;
use codex_hooks::SubagentHookContext;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
use codex_otel::HOOK_RUN_METRIC;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
@@ -28,12 +32,15 @@ use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::user_input::UserInput;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_thread_store::ReadThreadParams;
use serde_json::Value;
use crate::context::ContextualUserFragment;
use crate::context::HookAdditionalContext;
use crate::event_mapping::parse_turn_item;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::hook_names::HookToolName;
@@ -49,22 +56,6 @@ pub(crate) enum PreToolUseHookResult {
Blocked(String),
}
pub(crate) enum PendingInputHookDisposition {
Accepted(Box<PendingInputRecord>),
Blocked { additional_contexts: Vec<String> },
}
pub(crate) enum PendingInputRecord {
UserMessage {
content: Vec<UserInput>,
response_item: ResponseItem,
additional_contexts: Vec<String>,
},
ConversationItem {
response_item: ResponseItem,
},
}
struct ContextInjectingHookOutcome {
hook_events: Vec<HookCompletedEvent>,
outcome: HookRuntimeOutcome,
@@ -110,30 +101,55 @@ pub(crate) async fn run_pending_session_start_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
) -> bool {
let Some(session_start_source) = sess.take_pending_session_start_source().await else {
return false;
};
while let Some(session_start_source) = sess.take_pending_session_start_source().await {
// Pending session-start hooks are reused to dispatch thread-spawn subagent
// starts. Other subagent sessions are internal/system work and do not run
// start hooks.
let target = match &turn_context.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. })
if matches!(
session_start_source,
codex_hooks::SessionStartSource::Startup
) =>
{
let context = subagent_hook_context(sess, agent_role);
StartHookTarget::SubagentStart {
turn_id: turn_context.sub_id.clone(),
agent_id: context.agent_id,
agent_type: context.agent_type,
}
}
SessionSource::SubAgent(_) => return false,
_ => StartHookTarget::SessionStart {
source: session_start_source,
},
};
let request = codex_hooks::SessionStartRequest {
session_id: sess.session_id().into(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
target,
};
let hooks = sess.hooks();
let preview_runs = hooks.preview_session_start(&request);
if run_context_injecting_hook(
sess,
turn_context,
preview_runs,
hooks.run_session_start(request, Some(turn_context.sub_id.clone())),
)
.await
.record_additional_contexts(sess, turn_context)
.await
{
return true;
}
}
let request = codex_hooks::SessionStartRequest {
session_id: sess.session_id().into(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
source: session_start_source,
};
let hooks = sess.hooks();
let preview_runs = hooks.preview_session_start(&request);
run_context_injecting_hook(
sess,
turn_context,
preview_runs,
hooks.run_session_start(request, Some(turn_context.sub_id.clone())),
)
.await
.record_additional_contexts(sess, turn_context)
.await
false
}
/// Runs matching `PreToolUse` hooks before a tool executes.
@@ -151,6 +167,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
let request = PreToolUseRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -211,6 +228,7 @@ pub(crate) async fn run_permission_request_hooks(
let request = PermissionRequestRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
transcript_path: sess.hook_transcript_path().await,
@@ -252,6 +270,7 @@ pub(crate) async fn run_post_tool_use_hooks(
let request = PostToolUseRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -272,6 +291,76 @@ pub(crate) async fn run_post_tool_use_hooks(
outcome
}
pub(crate) async fn run_turn_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> StopOutcome {
// Resolve the stop hook kind from the session source before building the
// request. Root turns run Stop; thread-spawned child turns run SubagentStop.
let (target, transcript_path) = match &turn_context.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
agent_role,
parent_thread_id,
..
}) => {
let context = subagent_hook_context(sess, agent_role);
let agent_transcript_path = sess.hook_transcript_path().await;
let parent_transcript_path = match sess
.services
.thread_store
.read_thread(ReadThreadParams {
thread_id: *parent_thread_id,
include_archived: true,
include_history: false,
})
.await
{
Ok(thread) => thread.rollout_path,
Err(error) => {
tracing::warn!(
parent_thread_id = %parent_thread_id,
error = %error,
"failed to resolve parent transcript path for subagent hook"
);
None
}
};
(
StopHookTarget::SubagentStop {
agent_id: context.agent_id,
agent_type: context.agent_type,
agent_transcript_path,
},
parent_transcript_path,
)
}
// Internal/synthetic subagents do not expose user-configured lifecycle
// hooks, so there is no Stop or SubagentStop request to dispatch.
SessionSource::SubAgent(_) => return StopOutcome::default(),
_ => (StopHookTarget::Stop, sess.hook_transcript_path().await),
};
let request = codex_hooks::StopRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
stop_hook_active,
last_assistant_message,
target,
};
let hooks = sess.hooks();
emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await;
let mut outcome = hooks.run_stop(request).await;
emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await;
outcome
}
pub(crate) async fn run_pre_compact_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -280,6 +369,7 @@ pub(crate) async fn run_pre_compact_hooks(
let request = codex_hooks::PreCompactRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -318,6 +408,7 @@ pub(crate) async fn run_post_compact_hooks(
let request = codex_hooks::PostCompactRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
@@ -336,83 +427,126 @@ pub(crate) async fn run_post_compact_hooks(
}
}
pub(crate) async fn run_user_prompt_submit_hooks(
pub(crate) async fn run_legacy_after_agent_hook(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
prompt: String,
) -> HookRuntimeOutcome {
let request = UserPromptSubmitRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
prompt,
};
input: &[ResponseItem],
last_assistant_message: Option<String>,
) -> bool {
let mut abort_message = None;
let input_messages = input
.iter()
.filter_map(|item| match parse_turn_item(item) {
Some(TurnItem::UserMessage(user_message)) => Some(user_message.message()),
_ => None,
})
.collect();
let hooks = sess.hooks();
let preview_runs = hooks.preview_user_prompt_submit(&request);
run_context_injecting_hook(
sess,
turn_context,
preview_runs,
hooks.run_user_prompt_submit(request),
)
.await
for hook_outcome in hooks
.dispatch(codex_hooks::HookPayload {
session_id: sess.session_id().into(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
client: turn_context.app_server_client_name.clone(),
triggered_at: chrono::Utc::now(),
hook_event: codex_hooks::HookEvent::AfterAgent {
event: codex_hooks::HookEventAfterAgent {
thread_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
input_messages,
last_assistant_message,
},
},
})
.await
{
let hook_name = hook_outcome.hook_name;
let (error, should_abort) = match hook_outcome.result {
codex_hooks::HookResult::Success => continue,
codex_hooks::HookResult::FailedContinue(error) => (error, false),
codex_hooks::HookResult::FailedAbort(error) => (error, true),
};
let action = if should_abort {
"aborting operation"
} else {
"continuing"
};
tracing::warn!(
turn_id = %turn_context.sub_id,
hook_name = %hook_name,
error = %error,
"after_agent hook failed; {action}"
);
if should_abort && abort_message.is_none() {
abort_message = Some(format!(
"after_agent hook '{hook_name}' failed and aborted turn completion: {error}"
));
}
}
let Some(message) = abort_message else {
return false;
};
let event = EventMsg::Error(codex_protocol::protocol::ErrorEvent {
message,
codex_error_info: None,
});
sess.send_event(turn_context, event).await;
true
}
pub(crate) async fn inspect_pending_input(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
pending_input_item: ResponseInputItem,
) -> PendingInputHookDisposition {
let response_item = ResponseItem::from(pending_input_item);
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
let user_prompt_submit_outcome =
run_user_prompt_submit_hooks(sess, turn_context, user_message.message()).await;
if user_prompt_submit_outcome.should_stop {
PendingInputHookDisposition::Blocked {
additional_contexts: user_prompt_submit_outcome.additional_contexts,
}
} else {
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage {
content: user_message.content,
response_item,
additional_contexts: user_prompt_submit_outcome.additional_contexts,
}))
pending_input_item: &TurnInput,
) -> HookRuntimeOutcome {
match pending_input_item {
TurnInput::UserInput(content) => {
let request = UserPromptSubmitRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
prompt: UserMessageItem::new(content).message(),
};
let hooks = sess.hooks();
let preview_runs = hooks.preview_user_prompt_submit(&request);
run_context_injecting_hook(
sess,
turn_context,
preview_runs,
hooks.run_user_prompt_submit(request),
)
.await
}
} else {
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::ConversationItem {
response_item,
}))
TurnInput::ResponseInputItem(_) => HookRuntimeOutcome {
should_stop: false,
additional_contexts: Vec::new(),
},
}
}
pub(crate) async fn record_pending_input(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
pending_input: PendingInputRecord,
pending_input: TurnInput,
additional_contexts: Vec<String>,
) {
match pending_input {
PendingInputRecord::UserMessage {
content,
response_item,
additional_contexts,
} => {
sess.record_user_prompt_and_emit_turn_item(
turn_context.as_ref(),
content.as_slice(),
response_item,
)
.await;
record_additional_contexts(sess, turn_context, additional_contexts).await;
TurnInput::UserInput(content) => {
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), content.as_slice())
.await;
}
PendingInputRecord::ConversationItem { response_item } => {
TurnInput::ResponseInputItem(input) => {
let response_item = ResponseItem::from(input);
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
}
}
record_additional_contexts(sess, turn_context, additional_contexts).await;
}
async fn run_context_injecting_hook<Fut, Outcome>(
@@ -555,6 +689,8 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
};
let hook_source = match run.source {
@@ -595,6 +731,27 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String {
.to_string()
}
fn thread_spawn_subagent_hook_context(
sess: &Arc<Session>,
turn_context: &TurnContext,
) -> Option<SubagentHookContext> {
match &turn_context.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => {
Some(subagent_hook_context(sess, agent_role))
}
_ => None,
}
}
fn subagent_hook_context(sess: &Arc<Session>, agent_role: &Option<String>) -> SubagentHookContext {
SubagentHookContext {
agent_id: sess.thread_id().to_string(),
agent_type: agent_role
.clone()
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()),
}
}
fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
match value {
CompactionTrigger::Manual => "manual",
+2 -4
View File
@@ -7,12 +7,12 @@
mod apply_patch;
mod apps;
mod arc_monitor;
mod client;
mod client_common;
mod realtime_context;
mod realtime_conversation;
mod realtime_prompt;
mod responses_retry;
pub(crate) mod session;
pub use session::SteerInputError;
mod codex_thread;
@@ -21,7 +21,7 @@ mod compact_remote_v2;
mod config_lock;
mod credentialed_routes;
pub use codex_thread::CodexThread;
pub use codex_thread::CodexThreadTurnContextOverrides;
pub use codex_thread::CodexThreadSettingsOverrides;
pub use codex_thread::ThreadConfigSnapshot;
pub use session::turn_context::TurnContext;
mod agent;
@@ -91,13 +91,11 @@ pub(crate) use skills::SkillsManager;
pub(crate) use skills::build_available_skills;
pub(crate) use skills::build_skill_injections;
pub(crate) use skills::build_skill_name_counts;
pub(crate) use skills::collect_env_var_dependencies;
pub(crate) use skills::collect_explicit_skill_mentions;
pub(crate) use skills::default_skill_metadata_budget;
pub(crate) use skills::injection;
pub(crate) use skills::manager;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
pub(crate) use skills::resolve_skill_dependencies_for_turn;
pub(crate) use skills::skills_load_input_from_config;
mod stream_events_utils;
pub mod test_support;
+2 -2
View File
@@ -361,7 +361,7 @@ fn mcp_dependency_to_server_config(
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -391,7 +391,7 @@ fn mcp_dependency_to_server_config(
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+64 -152
View File
@@ -3,22 +3,12 @@ use std::collections::HashMap;
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use tracing::error;
use crate::arc_monitor::ArcMonitorOutcome;
use crate::arc_monitor::monitor_action;
use crate::config::Config;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::connectors;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianMcpAnnotations;
use crate::guardian::guardian_approval_request_to_json;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
@@ -36,6 +26,11 @@ use crate::turn_metadata::McpTurnMetadataContext;
use codex_analytics::AppInvocation;
use codex_analytics::InvocationType;
use codex_analytics::build_track_events_context;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
@@ -91,6 +86,7 @@ use std::sync::Arc;
use toml_edit::value;
use tracing::Instrument;
use tracing::Span;
use tracing::error;
use tracing::field::Empty;
use url::Url;
@@ -114,7 +110,7 @@ pub(crate) async fn handle_mcp_tool_call(
call_id: String,
server: String,
tool_name: String,
hook_tool_name: String,
hook_tool_name: HookToolName,
arguments: String,
) -> HandledMcpToolCall {
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
@@ -142,9 +138,14 @@ pub(crate) async fn handle_mcp_tool_call(
let metadata =
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
let mcp_app_resource_uri = metadata
.as_ref()
.and_then(|metadata| metadata.mcp_app_resource_uri.clone());
let item_metadata = McpToolCallItemMetadata {
mcp_app_resource_uri: metadata
.as_ref()
.and_then(|metadata| metadata.mcp_app_resource_uri.clone()),
plugin_id: metadata
.as_ref()
.and_then(|metadata| metadata.plugin_id.clone()),
};
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
connectors::app_tool_policy(
&turn_context.config,
@@ -175,7 +176,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
item_metadata.clone(),
"MCP tool call blocked by app configuration".to_string(),
/*already_started*/ false,
)
@@ -204,7 +205,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation.clone(),
mcp_app_resource_uri.clone(),
item_metadata.clone(),
)
.await;
@@ -229,7 +230,7 @@ pub(crate) async fn handle_mcp_tool_call(
&call_id,
invocation,
metadata.as_ref(),
mcp_app_resource_uri,
item_metadata,
)
.await;
}
@@ -240,7 +241,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
item_metadata.clone(),
message,
/*already_started*/ true,
)
@@ -253,19 +254,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
.await
}
McpToolApprovalDecision::BlockedBySafetyMonitor(message) => {
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
item_metadata.clone(),
message,
/*already_started*/ true,
)
@@ -296,7 +285,7 @@ pub(crate) async fn handle_mcp_tool_call(
&call_id,
invocation,
metadata.as_ref(),
mcp_app_resource_uri,
item_metadata,
)
.await
}
@@ -306,13 +295,19 @@ pub(crate) struct HandledMcpToolCall {
pub(crate) tool_input: JsonValue,
}
#[derive(Clone)]
struct McpToolCallItemMetadata {
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
}
async fn handle_approved_mcp_tool_call(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
) -> HandledMcpToolCall {
let server = invocation.server.clone();
maybe_mark_thread_memory_mode_polluted(sess, turn_context, &server).await;
@@ -381,7 +376,7 @@ async fn handle_approved_mcp_tool_call(
turn_context,
call_id,
invocation,
mcp_app_resource_uri,
item_metadata,
duration,
truncate_mcp_tool_result_for_event(&result),
)
@@ -661,7 +656,8 @@ async fn maybe_request_codex_apps_auth_elicitation(
};
let response = sess
.request_mcp_server_elicitation(turn_context, request_id, params)
.await;
.await
.response;
if !response
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept)
@@ -855,7 +851,7 @@ async fn notify_mcp_tool_call_started(
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
) {
let McpInvocation {
server,
@@ -867,7 +863,8 @@ async fn notify_mcp_tool_call_started(
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri,
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
plugin_id: item_metadata.plugin_id,
status: McpToolCallStatus::InProgress,
result: None,
error: None,
@@ -881,7 +878,7 @@ async fn notify_mcp_tool_call_completed(
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
duration: Duration,
result: Result<CallToolResult, String>,
) {
@@ -906,7 +903,8 @@ async fn notify_mcp_tool_call_completed(
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri,
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
plugin_id: item_metadata.plugin_id,
status,
result,
error,
@@ -966,7 +964,6 @@ enum McpToolApprovalDecision {
AcceptAndRemember,
Decline { message: Option<String> },
Cancel,
BlockedBySafetyMonitor(String),
}
pub(crate) struct McpToolApprovalMetadata {
@@ -974,6 +971,7 @@ pub(crate) struct McpToolApprovalMetadata {
connector_id: Option<String>,
connector_name: Option<String>,
connector_description: Option<String>,
plugin_id: Option<String>,
tool_title: Option<String>,
tool_description: Option<String>,
mcp_app_resource_uri: Option<String>,
@@ -983,6 +981,7 @@ pub(crate) struct McpToolApprovalMetadata {
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id";
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
async fn custom_mcp_tool_approval_mode(
@@ -1068,6 +1067,12 @@ fn build_mcp_tool_call_request_meta(
serde_json::Value::Object(codex_apps_meta),
);
}
if let Some(plugin_id) = metadata.and_then(|metadata| metadata.plugin_id.as_ref()) {
request_meta.insert(
MCP_TOOL_PLUGIN_ID_META_KEY.to_string(),
serde_json::Value::String(plugin_id.clone()),
);
}
(!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta))
}
@@ -1122,8 +1127,6 @@ pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this se
pub(crate) const MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC: &str = "__codex_mcp_decline__";
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again";
const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel";
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default";
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow";
pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool {
question_id
@@ -1155,7 +1158,7 @@ async fn maybe_request_mcp_tool_approval(
turn_context: &Arc<TurnContext>,
call_id: &str,
invocation: &McpInvocation,
hook_tool_name: &str,
hook_tool_name: &HookToolName,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
@@ -1176,31 +1179,6 @@ async fn maybe_request_mcp_tool_approval(
return None;
}
let mut monitor_reason = None;
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve;
if auto_approved_by_policy {
match maybe_monitor_auto_approved_mcp_tool_call(
sess,
turn_context,
invocation,
metadata,
approval_mode,
)
.await
{
ArcMonitorOutcome::Ok => return None,
ArcMonitorOutcome::AskUser(reason) => {
monitor_reason = Some(reason);
}
ArcMonitorOutcome::SteerModel(reason) => {
return Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
arc_monitor_interrupt_message(&reason),
));
}
}
}
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);
@@ -1215,7 +1193,7 @@ async fn maybe_request_mcp_tool_approval(
turn_context,
call_id,
PermissionRequestPayload {
tool_name: HookToolName::new(hook_tool_name),
tool_name: hook_tool_name.clone(),
tool_input: invocation
.arguments
.clone()
@@ -1247,7 +1225,7 @@ async fn maybe_request_mcp_tool_approval(
turn_context,
review_id.clone(),
build_guardian_mcp_tool_review_request(call_id, invocation, metadata),
monitor_reason.clone(),
/*retry_reason*/ None,
)
.await;
let decision = mcp_tool_approval_decision_from_guardian(sess, &review_id, decision).await;
@@ -1279,7 +1257,7 @@ async fn maybe_request_mcp_tool_approval(
.as_ref()
.map(|rendered_template| rendered_template.tool_params_display.clone())
.or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref()));
let mut question = build_mcp_tool_approval_question(
let question = build_mcp_tool_approval_question(
question_id.clone(),
&invocation.server,
&invocation.tool,
@@ -1289,8 +1267,6 @@ async fn maybe_request_mcp_tool_approval(
.as_ref()
.map(|rendered_template| rendered_template.question.as_str()),
);
question.question =
mcp_tool_approval_question_text(question.question, monitor_reason.as_deref());
if tool_call_mcp_elicitation_enabled {
let request_id = rmcp::model::RequestId::String(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
@@ -1307,17 +1283,16 @@ async fn maybe_request_mcp_tool_approval(
.or(invocation.arguments.as_ref()),
tool_params_display: tool_params_display.as_deref(),
question,
message_override: rendered_template.as_ref().and_then(|rendered_template| {
monitor_reason
.is_none()
.then_some(rendered_template.elicitation_message.as_str())
}),
message_override: rendered_template
.as_ref()
.map(|rendered_template| rendered_template.elicitation_message.as_str()),
prompt_options,
},
);
let decision = parse_mcp_tool_approval_elicitation_response(
sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params)
.await,
.await
.response,
&question_id,
);
let decision = normalize_approval_decision_for_mode(decision, approval_mode);
@@ -1353,37 +1328,6 @@ async fn maybe_request_mcp_tool_approval(
Some(decision)
}
async fn maybe_monitor_auto_approved_mcp_tool_call(
sess: &Session,
turn_context: &TurnContext,
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> ArcMonitorOutcome {
let action = prepare_arc_request_action(invocation, metadata);
monitor_action(
sess,
turn_context,
action,
mcp_tool_approval_callsite_mode(approval_mode, turn_context),
)
.await
}
fn prepare_arc_request_action(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
) -> serde_json::Value {
let request = build_guardian_mcp_tool_review_request("arc-monitor", invocation, metadata);
match guardian_approval_request_to_json(&request) {
Ok(action) => action,
Err(error) => {
error!(error = %error, "failed to serialize guardian MCP approval request for ARC");
serde_json::Value::Null
}
}
}
fn session_mcp_tool_approval_key(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
@@ -1458,18 +1402,6 @@ async fn mcp_tool_approval_decision_from_guardian(
}
}
fn mcp_tool_approval_callsite_mode(
approval_mode: AppToolApproval,
_turn_context: &TurnContext,
) -> &'static str {
match approval_mode {
AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW,
AppToolApproval::Auto | AppToolApproval::Prompt => {
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT
}
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP approval metadata reads through the session-owned manager guard"
@@ -1480,13 +1412,11 @@ pub(crate) async fn lookup_mcp_tool_metadata(
server: &str,
tool_name: &str,
) -> Option<McpToolApprovalMetadata> {
let tools = sess
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await;
let manager = sess.services.mcp_connection_manager.read().await;
let plugin_id = manager
.plugin_id_for_mcp_server_name(server)
.map(str::to_string);
let tools = manager.list_all_tools().await;
let tool_info = tools
.into_iter()
.find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?;
@@ -1519,6 +1449,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
connector_id: tool_info.connector_id,
connector_name: tool_info.connector_name,
connector_description,
plugin_id,
tool_title: tool_info.tool.title,
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()),
@@ -1660,24 +1591,6 @@ fn build_mcp_tool_approval_fallback_message(
format!("Allow {actor} to run tool \"{tool_name}\"?")
}
fn mcp_tool_approval_question_text(question: String, monitor_reason: Option<&str>) -> String {
match monitor_reason.map(str::trim) {
Some(reason) if !reason.is_empty() => {
format!("Tool call needs your approval. Reason: {reason}")
}
_ => question,
}
}
fn arc_monitor_interrupt_message(reason: &str) -> String {
let reason = reason.trim();
if reason.is_empty() {
"Tool call was cancelled because of safety risks.".to_string()
} else {
format!("Tool call was cancelled because of safety risks: {reason}")
}
}
fn build_mcp_tool_approval_elicitation_request(
sess: &Session,
turn_context: &TurnContext,
@@ -1979,8 +1892,7 @@ async fn apply_mcp_tool_approval_decision(
}
McpToolApprovalDecision::Accept
| McpToolApprovalDecision::Decline { .. }
| McpToolApprovalDecision::Cancel
| McpToolApprovalDecision::BlockedBySafetyMonitor(_) => {}
| McpToolApprovalDecision::Cancel => {}
}
}
@@ -2193,7 +2105,7 @@ async fn notify_mcp_tool_call_skip(
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
message: String,
already_started: bool,
) -> Result<CallToolResult, String> {
@@ -2203,7 +2115,7 @@ async fn notify_mcp_tool_call_skip(
turn_context,
call_id,
invocation.clone(),
mcp_app_resource_uri.clone(),
item_metadata.clone(),
)
.await;
}
@@ -2213,7 +2125,7 @@ async fn notify_mcp_tool_call_skip(
turn_context,
call_id,
invocation,
mcp_app_resource_uri,
item_metadata,
Duration::ZERO,
truncate_mcp_tool_result_for_event(&Err(message.clone())),
)
+82 -329
View File
@@ -5,6 +5,7 @@ use crate::session::tests::make_session_and_context;
use crate::session::tests::make_session_and_context_with_rx;
use crate::state::ActiveTurn;
use crate::test_support::models_manager_with_provider;
use crate::tools::hook_names::HookToolName;
use crate::turn_metadata::McpTurnMetadataContext;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
@@ -76,6 +77,7 @@ fn approval_metadata(
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
connector_description: connector_description.map(str::to_string),
plugin_id: None,
tool_title: tool_title.map(str::to_string),
tool_description: tool_description.map(str::to_string),
mcp_app_resource_uri: None,
@@ -404,17 +406,6 @@ fn prompt_mode_does_not_allow_persistent_remember() {
);
}
#[test]
fn approval_question_text_prepends_safety_reason() {
assert_eq!(
mcp_tool_approval_question_text(
"Allow this action?".to_string(),
Some("This tool may contact an external system."),
),
"Tool call needs your approval. Reason: This tool may contact an external system."
);
}
#[tokio::test]
async fn mcp_tool_call_span_records_expected_fields() {
let buffer: &'static std::sync::Mutex<Vec<u8>> =
@@ -1099,6 +1090,63 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() {
);
}
#[tokio::test]
async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() {
let (_, turn_context) = make_session_and_context().await;
let expected_turn_metadata = turn_context
.turn_metadata_state
.current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))
.expect("turn metadata");
let mut metadata = approval_metadata(
/*connector_id*/ None, /*connector_name*/ None,
/*connector_description*/ None, /*tool_title*/ None,
/*tool_description*/ None,
);
metadata.plugin_id = Some("sample@test".to_string());
assert_eq!(
build_mcp_tool_call_request_meta(&turn_context, "sample", "call-plugin", Some(&metadata),),
Some(serde_json::json!({
crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,
MCP_TOOL_PLUGIN_ID_META_KEY: "sample@test",
}))
);
}
#[tokio::test]
async fn mcp_tool_call_item_includes_plugin_id() {
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
notify_mcp_tool_call_started(
&session,
&turn_context,
"call-plugin",
McpInvocation {
server: "sample".to_string(),
tool: "echo".to_string(),
arguments: None,
},
McpToolCallItemMetadata {
mcp_app_resource_uri: None,
plugin_id: Some("sample@test".to_string()),
},
)
.await;
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())
.await
.expect("tool call item timed out")
.expect("tool call item event");
let EventMsg::ItemStarted(item_started) = event.msg else {
panic!("expected ItemStarted event");
};
let TurnItem::McpToolCall(item) = item_started.item else {
panic!("expected MCP tool call item");
};
assert_eq!(item.plugin_id.as_deref(), Some("sample@test"));
}
#[tokio::test]
async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() {
let (_, turn_context) = make_session_and_context().await;
@@ -1111,6 +1159,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
plugin_id: None,
tool_title: Some("Create Event".to_string()),
tool_description: Some("Create a calendar event.".to_string()),
mcp_app_resource_uri: None,
@@ -1207,11 +1256,6 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let environment = session
.services
.environment_manager
.default_environment()
.unwrap_or_else(|| session.services.environment_manager.local_environment());
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
&HashMap::new(),
turn_context.config.mcp_oauth_credentials_store_mode,
@@ -1220,13 +1264,14 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
turn_context.sub_id.clone(),
session.get_tx_event(),
turn_context.permission_profile(),
codex_mcp::McpRuntimeEnvironment::new(environment, {
codex_mcp::McpRuntimeContext::new(Arc::clone(&session.services.environment_manager), {
#[allow(deprecated)]
turn_context.cwd.to_path_buf()
}),
turn_context.config.codex_home.to_path_buf(),
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
/*host_owned_codex_apps_enabled*/ true,
turn_context.config.prefix_mcp_tool_names(),
rmcp::model::ElicitationCapability::default(),
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
@@ -1573,6 +1618,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: None,
tool_description: None,
mcp_app_resource_uri: None,
@@ -1603,42 +1649,6 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
);
}
#[test]
fn prepare_arc_request_action_serializes_mcp_tool_call_shape() {
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "browser_navigate".to_string(),
arguments: Some(serde_json::json!({
"url": "https://example.com",
})),
};
let action = prepare_arc_request_action(
&invocation,
Some(&approval_metadata(
/*connector_id*/ None,
Some("Playwright"),
/*connector_description*/ None,
Some("Navigate"),
/*tool_description*/ None,
)),
);
assert_eq!(
action,
serde_json::json!({
"tool": "mcp_tool_call",
"server": CODEX_APPS_MCP_SERVER_NAME,
"tool_name": "browser_navigate",
"arguments": {
"url": "https://example.com",
},
"connector_name": "Playwright",
"tool_title": "Navigate",
})
);
}
#[tokio::test(flavor = "current_thread")]
async fn guardian_review_decision_maps_to_mcp_tool_decision() {
let (session, _) = make_session_and_context().await;
@@ -1773,24 +1783,6 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
);
}
#[tokio::test]
async fn approval_callsite_mode_distinguishes_default_and_always_allow() {
let (_session, turn_context) = make_session_and_context().await;
assert_eq!(
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
"mcp_tool_call__default"
);
assert_eq!(
mcp_tool_approval_callsite_mode(AppToolApproval::Prompt, &turn_context),
"mcp_tool_call__default"
);
assert_eq!(
mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context),
"mcp_tool_call__always_allow"
);
}
#[test]
fn declined_elicitation_response_stays_decline() {
let response = parse_mcp_tool_approval_elicitation_response(
@@ -2290,6 +2282,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
@@ -2302,7 +2295,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
&turn_context,
"call-1",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Approve,
)
@@ -2363,6 +2356,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
@@ -2375,7 +2369,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
&turn_context,
"call-guardian",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2419,6 +2413,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
@@ -2431,7 +2426,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
&turn_context,
"call-mcp-hook",
&invocation,
"mcp__memory__create_entities",
&HookToolName::new("mcp__memory__create_entities"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2493,7 +2488,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
&turn_context,
"call-mcp-hook-no-metadata",
&invocation,
"mcp__memory__create_entities",
&HookToolName::new("mcp__memory__create_entities"),
/*metadata*/ None,
AppToolApproval::Auto,
)
@@ -2554,6 +2549,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
@@ -2572,7 +2568,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
&turn_context,
"call-mcp-remembered",
&invocation,
"mcp__memory__create_entities",
&HookToolName::new("mcp__memory__create_entities"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2640,6 +2636,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Reads calendar data.".to_string()),
mcp_app_resource_uri: None,
@@ -2652,7 +2649,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
&turn_context,
"call-guardian-deny",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2693,6 +2690,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
@@ -2709,7 +2707,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
&turn_context,
"call-prompt",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Prompt,
)
@@ -2727,244 +2725,13 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
}
#[tokio::test]
async fn approve_mode_skips_arc_interrupt_for_model() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "dangerous_tool",
"why": "high-risk action",
}],
})))
.expect(0)
.mount(&server)
.await;
async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "dangerous_tool".to_string(),
arguments: Some(serde_json::json!({ "id": 1 })),
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-2",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)
.await;
assert_eq!(decision, None);
}
#[tokio::test]
async fn custom_approve_mode_skips_arc_interrupt_for_model() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "dangerous_tool",
"why": "high-risk action",
}],
})))
.expect(0)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: "docs".to_string(),
tool: "dangerous_tool".to_string(),
arguments: Some(serde_json::json!({ "id": 1 })),
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-2-custom",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)
.await;
assert_eq!(decision, None);
}
#[tokio::test]
async fn approve_mode_skips_arc_interrupt_without_annotations() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "dangerous_tool",
"why": "high-risk action",
}],
})))
.expect(0)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "dangerous_tool".to_string(),
arguments: Some(serde_json::json!({ "id": 1 })),
};
let metadata = McpToolApprovalMetadata {
annotations: None,
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-3",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)
.await;
assert_eq!(decision, None);
}
#[tokio::test]
async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "dangerous_tool",
"why": "high-risk action",
}],
})))
.expect(0)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
turn_context
.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn_context.permission_profile = PermissionProfile::Disabled;
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
@@ -2978,6 +2745,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
plugin_id: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
@@ -2995,7 +2763,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
&turn_context,
"call-2",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
approval_mode,
)
@@ -3006,7 +2774,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
}
#[tokio::test]
async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
async fn approve_mode_skips_guardian_in_every_permission_mode() {
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
@@ -3019,22 +2787,6 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
.expect(0)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "ask-user",
"short_reason": "needs confirmation",
"rationale": "ARC wants a second review",
"risk_score": 65,
"risk_level": "medium",
"evidence": [{
"message": "dangerous_tool",
"why": "requires review",
}],
})))
.expect(0)
.mount(&server)
.await;
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
@@ -3046,6 +2798,7 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
plugin_id: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
@@ -3098,7 +2851,7 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
&turn_context,
"call-3",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Approve,
)
+3 -13
View File
@@ -3,7 +3,6 @@ use std::collections::HashSet;
use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_tools::ToolsConfig;
use crate::config::Config;
use crate::connectors;
@@ -18,9 +17,8 @@ pub(crate) struct McpToolExposure {
pub(crate) fn build_mcp_tool_exposure(
all_mcp_tools: &[McpToolInfo],
connectors: Option<&[connectors::AppInfo]>,
explicitly_enabled_connectors: &[connectors::AppInfo],
config: &Config,
tools_config: &ToolsConfig,
search_tool_enabled: bool,
) -> McpToolExposure {
let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
if let Some(connectors) = connectors {
@@ -31,7 +29,7 @@ pub(crate) fn build_mcp_tool_exposure(
));
}
let should_defer = tools_config.search_tool
let should_defer = search_tool_enabled
&& (config
.features
.enabled(Feature::ToolSearchAlwaysDeferMcpTools)
@@ -44,16 +42,8 @@ pub(crate) fn build_mcp_tool_exposure(
};
}
let direct_tools =
filter_codex_apps_mcp_tools(all_mcp_tools, explicitly_enabled_connectors, config);
let direct_tool_names = direct_tools
.iter()
.map(McpToolInfo::canonical_tool_name)
.collect::<HashSet<_>>();
deferred_tools.retain(|tool| !direct_tool_names.contains(&tool.canonical_tool_name()));
McpToolExposure {
direct_tools,
direct_tools: Vec::new(),
deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools),
}
}
+9 -107
View File
@@ -2,17 +2,9 @@ use std::collections::HashSet;
use std::sync::Arc;
use codex_features::Feature;
use codex_features::Features;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo;
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SessionSource;
use codex_tools::ToolName;
use codex_tools::ToolsConfig;
use codex_tools::ToolsConfigParams;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
@@ -78,7 +70,7 @@ fn numbered_mcp_tools(count: usize) -> Vec<ToolInfo> {
make_mcp_tool(
"rmcp",
&tool_name,
"mcp__rmcp__",
"mcp__rmcp",
&tool_name,
/*connector_id*/ None,
/*connector_name*/ None,
@@ -94,38 +86,13 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
.collect()
}
async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
let config = test_config().await;
let model_info =
construct_model_info_offline_for_tests("gpt-5.4", &config.to_models_manager_config());
let features = Features::with_defaults();
let available_models = Vec::new();
let mut tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
tools_config.search_tool = search_tool;
tools_config
}
#[tokio::test]
async fn directly_exposes_small_effective_tool_sets() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
let exposure = build_mcp_tool_exposure(
&mcp_tools,
/*connectors*/ None,
&[],
&config,
&tools_config,
&mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true,
);
assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools));
@@ -135,15 +102,10 @@ async fn directly_exposes_small_effective_tool_sets() {
#[tokio::test]
async fn searches_large_effective_tool_sets() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
let exposure = build_mcp_tool_exposure(
&mcp_tools,
/*connectors*/ None,
&[],
&config,
&tools_config,
&mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true,
);
assert!(exposure.direct_tools.is_empty());
@@ -155,69 +117,17 @@ async fn searches_large_effective_tool_sets() {
}
#[tokio::test]
async fn directly_exposes_explicit_apps_without_deferred_overlap() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
mcp_tools.push(make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
"mcp__codex_apps__calendar",
"_create_event",
Some("calendar"),
Some("Calendar"),
));
let connectors = vec![make_connector("calendar", "Calendar")];
let exposure = build_mcp_tool_exposure(
&mcp_tools,
Some(connectors.as_slice()),
connectors.as_slice(),
&config,
&tools_config,
);
let direct_tool_names = tool_names(&exposure.direct_tools);
assert_eq!(
direct_tool_names,
HashSet::from([ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
)])
);
assert_eq!(
exposure.deferred_tools.as_ref().map(Vec::len),
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1)
);
let deferred_tools = exposure
.deferred_tools
.as_ref()
.expect("large tool sets should be discoverable through tool_search");
let deferred_tool_names = tool_names(deferred_tools);
assert!(
direct_tool_names.is_disjoint(&deferred_tool_names),
"direct tools should not also be deferred: {direct_tool_names:?}"
);
assert!(!deferred_tool_names.contains(&ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
)));
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool_0")));
}
#[tokio::test]
async fn always_defer_feature_preserves_explicit_apps() {
async fn always_defer_feature_defers_apps_too() {
let mut config = test_config().await;
config
.features
.enable(Feature::ToolSearchAlwaysDeferMcpTools)
.expect("test config should allow feature update");
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mcp_tools = vec![
make_mcp_tool(
"rmcp",
"tool",
"mcp__rmcp__",
"mcp__rmcp",
"tool",
/*connector_id*/ None,
/*connector_name*/ None,
@@ -236,26 +146,18 @@ async fn always_defer_feature_preserves_explicit_apps() {
let exposure = build_mcp_tool_exposure(
&mcp_tools,
Some(connectors.as_slice()),
connectors.as_slice(),
&config,
&tools_config,
/*search_tool_enabled*/ true,
);
let direct_tool_names = tool_names(&exposure.direct_tools);
assert_eq!(
direct_tool_names,
HashSet::from([ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
)])
);
assert!(exposure.direct_tools.is_empty());
let deferred_tools = exposure
.deferred_tools
.as_ref()
.expect("MCP tools should be discoverable through tool_search");
let deferred_tool_names = tool_names(deferred_tools);
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool")));
assert!(!deferred_tool_names.contains(&ToolName::namespaced(
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp", "tool")));
assert!(deferred_tool_names.contains(&ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
)));
+6 -4
View File
@@ -43,14 +43,16 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
(None, "shell_command") => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| {
if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) {
if !invocation.turn.config.permissions.allow_login_shell
&& params.login == Some(true)
{
#[allow(deprecated)]
let cwd = invocation.turn.resolve_path(params.workdir).to_path_buf();
return (Vec::new(), cwd);
}
let use_login_shell = params
.login
.unwrap_or(invocation.turn.tools_config.allow_login_shell);
.unwrap_or(invocation.turn.config.permissions.allow_login_shell);
let command = invocation
.session
.user_shell()
@@ -65,8 +67,8 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
let command = crate::tools::handlers::unified_exec::get_command(
&params,
invocation.session.user_shell(),
&invocation.turn.tools_config.unified_exec_shell_mode,
invocation.turn.tools_config.allow_login_shell,
&invocation.turn.unified_exec_shell_mode,
invocation.turn.config.permissions.allow_login_shell,
)
.ok()?;
#[allow(deprecated)]
+20 -8
View File
@@ -1,4 +1,6 @@
use crate::config::find_codex_home;
use crate::config::is_builtin_permission_profile_name;
use crate::config::reject_unknown_builtin_permission_profile;
use crate::config::resolve_permission_profile;
use crate::exec_policy::ExecPolicyError;
use crate::exec_policy::format_exec_policy_error_with_source;
@@ -13,6 +15,7 @@ use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::LoaderOverrides;
use codex_config::loader::load_config_layers_state;
use codex_config::merge_toml_values;
use codex_config::permissions_toml::NetworkMitmActionToml;
use codex_config::permissions_toml::NetworkMitmHookToml;
use codex_config::permissions_toml::NetworkMitmToml;
@@ -122,6 +125,7 @@ fn network_constraints_from_trusted_layers(
layers: &ConfigLayerStack,
) -> Result<NetworkProxyConstraints> {
let mut constraints = NetworkProxyConstraints::default();
let mut merged = toml::Value::Table(toml::map::Map::new());
for layer in layers.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
@@ -130,10 +134,12 @@ fn network_constraints_from_trusted_layers(
continue;
}
let parsed = network_tables_from_toml(&layer.config)?;
if let Some(network) = selected_network_from_tables(parsed)? {
apply_network_constraints(network, &mut constraints);
}
merge_toml_values(&mut merged, &layer.config);
}
let parsed = network_tables_from_toml(&merged)?;
if let Some(network) = selected_network_from_tables(parsed)? {
apply_network_constraints(network, &mut constraints);
}
Ok(constraints)
}
@@ -194,13 +200,17 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
let Some(default_permissions) = parsed.default_permissions else {
return Ok(None);
};
if is_builtin_permission_profile_name(&default_permissions) {
return Ok(None);
}
reject_unknown_builtin_permission_profile(&default_permissions)?;
let permissions = parsed
.permissions
.context("default_permissions requires a `[permissions]` table for network settings")?;
let profile = resolve_permission_profile(&permissions, &default_permissions)
.map_err(anyhow::Error::from)?;
Ok(profile.network.clone())
Ok(profile.profile.network)
}
#[cfg(test)]
@@ -262,14 +272,16 @@ fn config_from_layers(
layers: &ConfigLayerStack,
exec_policy: &codex_execpolicy::Policy,
) -> Result<NetworkProxyConfig> {
let mut accumulator = NetworkConfigAccumulator::default();
let mut merged = toml::Value::Table(toml::map::Map::new());
for layer in layers.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
let parsed = network_tables_from_toml(&layer.config)?;
accumulator.apply_network_tables(parsed)?;
merge_toml_values(&mut merged, &layer.config);
}
let parsed = network_tables_from_toml(&merged)?;
let mut accumulator = NetworkConfigAccumulator::default();
accumulator.apply_network_tables(parsed)?;
let mut config = accumulator.finish()?;
apply_exec_policy_network_rules(&mut config, exec_policy);
Ok(config)
+361 -23
View File
@@ -1,19 +1,27 @@
use super::*;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
use codex_execpolicy::Decision;
use codex_execpolicy::NetworkRuleProtocol;
use codex_execpolicy::Policy;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn higher_precedence_profile_network_overlays_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
[permissions.workspace.network.domains]
[permissions.dev.network.domains]
"lower.example.com" = "allow"
"blocked.example.com" = "deny"
"#,
@@ -21,11 +29,11 @@ default_permissions = "workspace"
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
[permissions.workspace.network.domains]
[permissions.dev.network.domains]
"higher.example.com" = "allow"
"#,
)
@@ -60,11 +68,11 @@ default_permissions = "workspace"
fn higher_precedence_profile_network_overrides_matching_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
[permissions.workspace.network.domains]
[permissions.dev.network.domains]
"shared.example.com" = "deny"
"other.example.com" = "allow"
"#,
@@ -72,11 +80,11 @@ default_permissions = "workspace"
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
[permissions.workspace.network.domains]
[permissions.dev.network.domains]
"shared.example.com" = "allow"
"#,
)
@@ -221,9 +229,9 @@ fn execpolicy_network_rules_overlay_network_lists() {
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
dangerously_allow_all_unix_sockets = true
"#,
)
@@ -240,15 +248,345 @@ dangerously_allow_all_unix_sockets = true
assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true));
}
#[test]
fn selected_network_from_tables_ignores_builtin_profile_without_permissions_table() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = ":workspace"
"#,
)
.expect("built-in profile config should parse");
let network = selected_network_from_tables(
network_tables_from_toml(&config).expect("built-in profile config should deserialize"),
)
.expect("built-in profile selection should not require permissions tables");
assert_eq!(network, None);
}
#[test]
fn selected_network_from_tables_rejects_unknown_builtin_profile_without_permissions_table() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = ":unknown"
"#,
)
.expect("unknown built-in config should parse");
let err = selected_network_from_tables(
network_tables_from_toml(&config).expect("unknown built-in config should deserialize"),
)
.expect_err("unknown built-in profile should be rejected");
assert_eq!(
err.to_string(),
"default_permissions refers to unknown built-in profile `:unknown`"
);
}
#[test]
fn selected_network_from_tables_resolves_builtin_workspace_parent() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = "dev"
[permissions.dev]
extends = ":workspace"
[permissions.dev.network]
enabled = true
[permissions.dev.network.domains]
"child.example.com" = "allow"
"#,
)
.expect("dev extension config should parse");
let network = selected_network_from_tables(
network_tables_from_toml(&config).expect("dev extension config should deserialize"),
)
.expect("dev extension should resolve")
.expect("dev extension should expose child network config");
assert_eq!(
network,
NetworkToml {
enabled: Some(true),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"child.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}
);
}
#[test]
fn selected_network_from_tables_resolves_permission_profile_inheritance() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = "dev"
[permissions.base.network]
enabled = true
dangerously_allow_all_unix_sockets = true
[permissions.base.network.domains]
"base.example.com" = "allow"
"shared.example.com" = "deny"
[permissions.dev]
extends = "base"
[permissions.dev.network]
allow_local_binding = true
[permissions.dev.network.domains]
"child.example.com" = "allow"
"shared.example.com" = "allow"
"#,
)
.expect("permissions profiles should parse");
let network = selected_network_from_tables(
network_tables_from_toml(&config).expect("permissions profiles should deserialize"),
)
.expect("permissions profiles should select a network table")
.expect("network table should be present");
assert_eq!(
network,
NetworkToml {
enabled: Some(true),
dangerously_allow_all_unix_sockets: Some(true),
allow_local_binding: Some(true),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([
(
"base.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"child.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"shared.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
]),
}),
..Default::default()
}
);
}
#[test]
fn config_from_layers_resolves_inherited_profiles_across_layers() {
let lower_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::toml! {
[permissions.base.network.domains]
"base.example.com" = "allow"
}
.into(),
);
let higher_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::toml! {
default_permissions = "dev"
[permissions.dev]
extends = "base"
[permissions.dev.network.domains]
"child.example.com" = "allow"
}
.into(),
);
let layers = ConfigLayerStack::new(
vec![lower_layer, higher_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("layer stack should be valid");
let config =
config_from_layers(&layers, &Policy::empty()).expect("inherited profiles should load");
assert_eq!(
config.network.allowed_domains(),
Some(vec![
"base.example.com".to_string(),
"child.example.com".to_string(),
])
);
}
#[test]
fn config_from_layers_normalizes_profile_network_domains_before_merging_layers() {
let lower_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::toml! {
default_permissions = "dev"
[permissions.dev.network.domains]
"example.com" = "deny"
}
.into(),
);
let higher_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::toml! {
[permissions.dev.network.domains]
"EXAMPLE.COM" = "allow"
}
.into(),
);
let layers = ConfigLayerStack::new(
vec![lower_layer, higher_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("layer stack should be valid");
let config = config_from_layers(&layers, &Policy::empty())
.expect("network domain layer precedence should load");
assert_eq!(
config.network.allowed_domains(),
Some(vec!["example.com".to_string()])
);
assert_eq!(config.network.denied_domains(), None);
}
#[test]
fn config_from_layers_uses_only_the_final_selected_profile_network() {
let lower_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::toml! {
default_permissions = "dev"
[permissions.dev.network.domains]
"lower.example.com" = "allow"
}
.into(),
);
let higher_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::toml! {
default_permissions = ":workspace"
}
.into(),
);
let layers = ConfigLayerStack::new(
vec![lower_layer, higher_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("layer stack should be valid");
let config = config_from_layers(&layers, &Policy::empty())
.expect("final built-in profile selection should load");
assert_eq!(config.network.allowed_domains(), None);
assert_eq!(config.network.denied_domains(), None);
}
#[test]
fn trusted_constraints_use_only_the_final_selected_profile_network() {
let lower_layer = ConfigLayerEntry::new(
ConfigLayerSource::System {
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/system.toml"))
.expect("system config path should be absolute"),
},
toml::toml! {
default_permissions = "dev"
[permissions.dev.network.domains]
"managed.example.com" = "allow"
}
.into(),
);
let higher_layer = ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/managed.toml"))
.expect("managed config path should be absolute"),
},
toml::toml! {
default_permissions = ":workspace"
}
.into(),
);
let layers = ConfigLayerStack::new(
vec![lower_layer, higher_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("layer stack should be valid");
let constraints = network_constraints_from_trusted_layers(&layers)
.expect("final built-in trusted selection should load");
assert_eq!(constraints.allowed_domains, None);
assert_eq!(constraints.denied_domains, None);
}
#[test]
fn trusted_constraints_normalize_profile_network_domains_before_merging_layers() {
let lower_layer = ConfigLayerEntry::new(
ConfigLayerSource::System {
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/system.toml"))
.expect("system config path should be absolute"),
},
toml::toml! {
default_permissions = "dev"
[permissions.dev.network.domains]
"example.com" = "deny"
}
.into(),
);
let higher_layer = ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/managed.toml"))
.expect("managed config path should be absolute"),
},
toml::toml! {
[permissions.dev.network.domains]
"EXAMPLE.COM" = "allow"
}
.into(),
);
let layers = ConfigLayerStack::new(
vec![lower_layer, higher_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("layer stack should be valid");
let constraints = network_constraints_from_trusted_layers(&layers)
.expect("trusted network domain layer precedence should load");
assert_eq!(
constraints.allowed_domains,
Some(vec!["example.com".to_string()])
);
assert_eq!(constraints.denied_domains, None);
}
#[test]
fn apply_network_constraints_skips_empty_domain_sides() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
[permissions.workspace.network.domains]
[permissions.dev.network.domains]
"managed.example.com" = "allow"
"#,
)
@@ -273,22 +611,22 @@ default_permissions = "workspace"
fn apply_network_constraints_overlay_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
[permissions.workspace.network.domains]
[permissions.dev.network.domains]
"blocked.example.com" = "deny"
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
default_permissions = "dev"
[permissions.workspace.network]
[permissions.dev.network]
[permissions.workspace.network.domains]
[permissions.dev.network.domains]
"api.example.com" = "allow"
"#,
)
+3 -6
View File
@@ -32,17 +32,14 @@ pub async fn maybe_migrate_personality(
return Ok(PersonalityMigrationStatus::SkippedMarker);
}
let config_profile = config_toml
.get_config_profile(/*override_profile*/ None)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
if config_toml.personality.is_some() || config_profile.personality.is_some() {
if config_toml.personality.is_some() {
create_marker(&marker_path).await?;
return Ok(PersonalityMigrationStatus::SkippedExplicitPersonality);
}
let model_provider_id = config_profile
let model_provider_id = config_toml
.model_provider
.or_else(|| config_toml.model_provider.clone())
.clone()
.unwrap_or_else(|| "openai".to_string());
if !has_recorded_sessions(codex_home, model_provider_id.as_str(), state_db).await? {
@@ -72,6 +72,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
})),
};
+7 -13
View File
@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::sync::Arc;
use codex_exec_server::EnvironmentManager;
@@ -46,9 +45,12 @@ pub async fn build_prompt_input(
Arc::clone(&auth_manager),
SessionSource::Exec,
Arc::new(
EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths)
.await
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
EnvironmentManager::from_codex_home(
config.codex_home.clone(),
Some(local_runtime_paths),
)
.await
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
),
empty_extension_registry(),
/*analytics_events_client*/ None,
@@ -86,15 +88,7 @@ pub(crate) async fn build_prompt_input_from_session(
.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities);
let router = built_tools(
sess,
turn_context.as_ref(),
&prompt_input,
&HashSet::new(),
Some(turn_context.turn_skills.outcome.as_ref()),
&CancellationToken::new(),
)
.await?;
let router = built_tools(sess, turn_context.as_ref(), &CancellationToken::new()).await?;
let base_instructions = sess.get_base_instructions().await;
let prompt = build_prompt(
prompt_input,
@@ -641,12 +641,14 @@ async fn prepare_realtime_start(
realtime_request_headers(
requested_realtime_session_id.as_deref(),
Some(realtime_api_key.as_str()),
version,
)?
}
ConversationStartTransport::Webrtc { .. } => {
realtime_request_headers(
requested_realtime_session_id.as_deref(),
/*api_key*/ None,
version,
)?
}
};
@@ -973,9 +975,14 @@ fn realtime_api_key(auth: Option<&CodexAuth>, provider: &ModelProviderInfo) -> C
fn realtime_request_headers(
realtime_session_id: Option<&str>,
api_key: Option<&str>,
version: RealtimeWsVersion,
) -> CodexResult<Option<HeaderMap>> {
let mut headers = HeaderMap::new();
if version == RealtimeWsVersion::V1 {
headers.insert("openai-alpha", HeaderValue::from_static("quicksilver=v1"));
}
if let Some(realtime_session_id) = realtime_session_id
&& let Ok(realtime_session_id) = HeaderValue::from_str(realtime_session_id)
{
@@ -1,9 +1,11 @@
use super::RealtimeHandoffState;
use super::RealtimeSessionKind;
use super::realtime_delegation_from_handoff;
use super::realtime_request_headers;
use super::realtime_text_from_handoff_request;
use super::wrap_realtime_delegation_input;
use async_channel::bounded;
use codex_config::config_toml::RealtimeWsVersion;
use codex_protocol::protocol::RealtimeHandoffRequested;
use codex_protocol::protocol::RealtimeTranscriptEntry;
use pretty_assertions::assert_eq;
@@ -137,3 +139,28 @@ async fn clears_active_handoff_explicitly() {
*state.active_handoff.lock().await = None;
assert_eq!(state.active_handoff.lock().await.clone(), None);
}
#[test]
fn uses_quicksilver_alpha_header_for_realtime_v1() {
let headers =
realtime_request_headers(Some("session_1"), Some("sk-test"), RealtimeWsVersion::V1)
.expect("headers")
.expect("headers");
assert_eq!(
headers
.get("openai-alpha")
.and_then(|value| value.to_str().ok()),
Some("quicksilver=v1")
);
}
#[test]
fn omits_quicksilver_alpha_header_for_realtime_v2() {
let headers =
realtime_request_headers(Some("session_1"), Some("sk-test"), RealtimeWsVersion::V2)
.expect("headers")
.expect("headers");
assert!(headers.get("openai-alpha").is_none());
}
+105
View File
@@ -0,0 +1,105 @@
//! Shared retry and transport fallback decisions for Responses requests.
use std::time::Duration;
use crate::client::ModelClientSession;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::util::backoff;
use codex_protocol::error::CodexErr;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::WarningEvent;
use tracing::warn;
#[derive(Debug, Clone, Copy)]
pub(crate) enum ResponsesStreamRequest {
Sampling,
RemoteCompactionV2,
}
/// Handles a retryable stream error and returns `Ok(())` when the caller should
/// retry the request loop.
pub(crate) async fn handle_retryable_response_stream_error(
retries: &mut u64,
max_retries: u64,
err: CodexErr,
client_session: &mut ModelClientSession,
sess: &Session,
turn_context: &TurnContext,
request: ResponsesStreamRequest,
) -> Result<(), CodexErr> {
if *retries >= max_retries
&& client_session.try_switch_fallback_transport(
&turn_context.session_telemetry,
&turn_context.model_info,
)
{
sess.send_event(
turn_context,
EventMsg::Warning(WarningEvent {
message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"),
}),
)
.await;
*retries = 0;
return Ok(());
}
if *retries < max_retries {
*retries += 1;
let retry_count = *retries;
let delay = match &err {
CodexErr::Stream(_, requested_delay) => {
requested_delay.unwrap_or_else(|| backoff(retry_count))
}
_ => backoff(retry_count),
};
log_retry(request, turn_context, &err, retry_count, max_retries, delay);
// In release builds, hide the first websocket retry notification to reduce noisy
// transient reconnect messages. In debug builds, keep full visibility for diagnosis.
let report_error = retry_count > 1
|| cfg!(debug_assertions)
|| !sess.services.model_client.responses_websocket_enabled();
if report_error {
// Surface retry information to any UI/front-end so the user understands what is
// happening instead of staring at a seemingly frozen screen.
sess.notify_stream_error(
turn_context,
format!("Reconnecting... {retry_count}/{max_retries}"),
err,
)
.await;
}
tokio::time::sleep(delay).await;
return Ok(());
}
Err(err)
}
fn log_retry(
request: ResponsesStreamRequest,
turn_context: &TurnContext,
err: &CodexErr,
retries: u64,
max_retries: u64,
delay: Duration,
) {
match request {
ResponsesStreamRequest::Sampling => {
warn!(
"stream disconnected - retrying sampling request ({retries}/{max_retries} in {delay:?})...",
);
}
ResponsesStreamRequest::RemoteCompactionV2 => {
warn!(
turn_id = %turn_context.sub_id,
retries,
max_retries,
compact_error = %err,
"remote compaction v2 stream failed; retrying request after delay"
);
}
}
}
+65 -62
View File
@@ -1,20 +1,16 @@
use super::*;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn permission_profile_for_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
}
#[test]
fn test_writable_roots_constraint() {
// Use a temporary directory as our workspace to avoid touching
@@ -32,36 +28,34 @@ fn test_writable_roots_constraint() {
// Policy limited to the workspace only; exclude system temp roots so
// only `cwd` is writable by default.
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let workspace_only_file_system_policy = FileSystemSandboxPolicy::workspace_write(
&[],
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
assert!(is_write_patch_constrained_to_writable_paths(
&add_inside,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&workspace_only_file_system_policy,
&cwd,
));
assert!(!is_write_patch_constrained_to_writable_paths(
&add_outside,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&workspace_only_file_system_policy,
&cwd,
));
// With the parent dir explicitly added as a writable root, the
// outside write should be permitted.
let policy_with_parent = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![parent],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy_with_parent = FileSystemSandboxPolicy::workspace_write(
std::slice::from_ref(&parent),
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
assert!(is_write_patch_constrained_to_writable_paths(
&add_outside,
&FileSystemSandboxPolicy::from(&policy_with_parent),
&file_system_policy_with_parent,
&cwd,
));
}
@@ -73,16 +67,17 @@ fn external_sandbox_auto_approves_in_on_request() {
let add_inside_path = cwd.join("inner.txt");
let add_inside = ApplyPatchAction::new_add_for_test(&add_inside_path, "".to_string());
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Enabled,
let permission_profile = PermissionProfile::External {
network: NetworkSandboxPolicy::Enabled,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::external_sandbox();
assert_eq!(
assess_patch_safety(
&add_inside,
AskForApproval::OnRequest,
&permission_profile_for_policy(&policy),
&FileSystemSandboxPolicy::from(&policy),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled
),
@@ -100,19 +95,20 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() {
let parent = cwd.parent().unwrap();
let outside_path = parent.join("outside.txt");
let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string());
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let permission_profile = PermissionProfile::workspace_write_with(
&[],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
assert_eq!(
assess_patch_safety(
&add_outside,
AskForApproval::OnRequest,
&permission_profile_for_policy(&policy_workspace_only),
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -128,8 +124,8 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() {
request_permissions: true,
mcp_elicitations: true,
}),
&permission_profile_for_policy(&policy_workspace_only),
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -144,12 +140,13 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() {
let parent = cwd.parent().unwrap();
let outside_path = parent.join("outside.txt");
let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string());
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let permission_profile = PermissionProfile::workspace_write_with(
&[],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
assert_eq!(
assess_patch_safety(
@@ -161,8 +158,8 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() {
request_permissions: true,
mcp_elicitations: true,
}),
&permission_profile_for_policy(&policy_workspace_only),
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -178,9 +175,8 @@ fn read_only_policy_rejects_patch_with_read_only_reason() {
let cwd = tmp.path().abs();
let inside_path = cwd.join("inside.txt");
let action = ApplyPatchAction::new_add_for_test(&inside_path, "".to_string());
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd);
let permission_profile = PermissionProfile::read_only();
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
assert!(!is_write_patch_constrained_to_writable_paths(
&action,
@@ -191,7 +187,7 @@ fn read_only_policy_rejects_patch_with_read_only_reason() {
assess_patch_safety(
&action,
AskForApproval::Never,
&permission_profile_for_policy(&sandbox_policy),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
@@ -208,8 +204,8 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
let blocked_path = cwd.join("blocked.txt");
let blocked_absolute = blocked_path;
let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string());
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
let permission_profile = PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
@@ -222,7 +218,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
path: FileSystemPath::Path {
path: blocked_absolute,
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -235,7 +231,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
assess_patch_safety(
&action,
AskForApproval::OnRequest,
&permission_profile_for_policy(&sandbox_policy),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
@@ -252,8 +248,8 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
let blocked_absolute = blocked_path;
let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd);
let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string());
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
let permission_profile = PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
@@ -279,7 +275,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
assess_patch_safety(
&action,
AskForApproval::OnRequest,
&permission_profile_for_policy(&sandbox_policy),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
@@ -294,14 +290,21 @@ fn missing_project_dot_codex_config_requires_approval() {
let cwd = tmp.path().abs();
let config_path = cwd.join(".codex").join("config.toml");
let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string());
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd);
let permission_profile = PermissionProfile::workspace_write_with(
&[],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
let mut file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
file_system_sandbox_policy
.entries
.push(FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: cwd.join(".codex"),
},
access: FileSystemAccessMode::Read,
});
assert!(!is_write_patch_constrained_to_writable_paths(
&action,
@@ -312,7 +315,7 @@ fn missing_project_dot_codex_config_requires_approval() {
assess_patch_safety(
&action,
AskForApproval::OnRequest,
&permission_profile_for_policy(&sandbox_policy),
&permission_profile,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
-14
View File
@@ -1,24 +1,10 @@
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
#[cfg(test)]
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxType;
use codex_sandboxing::get_platform_sandbox;
use codex_sandboxing::policy_transforms::should_require_platform_sandbox;
use std::path::Path;
#[cfg(test)]
pub(crate) fn sandbox_tag(
policy: &SandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
) -> &'static str {
permission_profile_sandbox_tag(
&PermissionProfile::from_legacy_sandbox_policy(policy),
windows_sandbox_level,
/*enforce_managed_network*/ false,
)
}
pub(crate) fn permission_profile_sandbox_tag(
profile: &PermissionProfile,
windows_sandbox_level: WindowsSandboxLevel,
+10 -10
View File
@@ -1,6 +1,5 @@
use super::permission_profile_policy_tag;
use super::permission_profile_sandbox_tag;
use super::sandbox_tag;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
@@ -10,8 +9,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxType;
use codex_sandboxing::get_platform_sandbox;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -20,29 +17,32 @@ use std::path::Path;
#[test]
fn danger_full_access_is_untagged_even_when_linux_sandbox_defaults_apply() {
let actual = sandbox_tag(
&SandboxPolicy::DangerFullAccess,
let actual = permission_profile_sandbox_tag(
&PermissionProfile::Disabled,
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);
assert_eq!(actual, "none");
}
#[test]
fn external_sandbox_keeps_external_tag_when_linux_sandbox_defaults_apply() {
let actual = sandbox_tag(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
let actual = permission_profile_sandbox_tag(
&PermissionProfile::External {
network: NetworkSandboxPolicy::Enabled,
},
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);
assert_eq!(actual, "external");
}
#[test]
fn default_linux_sandbox_uses_platform_sandbox_tag() {
let actual = sandbox_tag(
&SandboxPolicy::new_read_only_policy(),
let actual = permission_profile_sandbox_tag(
&PermissionProfile::read_only(),
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);
let expected = get_platform_sandbox(/*windows_sandbox_enabled*/ false)
.map(SandboxType::as_metric_tag)
+138 -203
View File
@@ -42,15 +42,15 @@ use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::ThreadSettingsSnapshot;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use crate::context_manager::is_user_turn_boundary;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::RequestId as ProtocolRequestId;
@@ -83,19 +83,6 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
.await;
}
pub async fn override_turn_context(sess: &Session, sub_id: String, updates: SessionSettingsUpdate) {
if let Err(err) = sess.update_settings(updates).await {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: err.to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
}
}
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
user_input_or_turn_inner(
sess,
@@ -106,140 +93,132 @@ pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
.await;
}
pub async fn update_thread_settings(
sess: &Arc<Session>,
sub_id: String,
thread_settings: ThreadSettingsOverrides,
) {
let updates = thread_settings_update(sess, thread_settings).await;
let msg = match sess.update_settings(updates).await {
Ok(()) => thread_settings_applied_event(sess).await,
Err(err) => EventMsg::Error(ErrorEvent {
message: format!("invalid thread settings override: {err}"),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
};
sess.send_event_raw(Event { id: sub_id, msg }).await;
}
async fn thread_settings_update(
sess: &Session,
thread_settings: ThreadSettingsOverrides,
) -> SessionSettingsUpdate {
let ThreadSettingsOverrides {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} = thread_settings;
let collaboration_mode = match collaboration_mode {
Some(collaboration_mode) => collaboration_mode,
None => {
let state = sess.state.lock().await;
// Model and reasoning effort live in CollaborationMode settings today, so
// partial thread-settings updates refresh those fields on the active mode.
state
.session_configuration
.collaboration_mode
.with_updates(model, effort, /*developer_instructions*/ None)
}
};
SessionSettingsUpdate {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
collaboration_mode: Some(collaboration_mode),
reasoning_summary: summary,
service_tier,
personality,
..Default::default()
}
}
async fn thread_settings_applied_event(sess: &Session) -> EventMsg {
let snapshot = {
let state = sess.state.lock().await;
state.session_configuration.thread_config_snapshot()
};
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
thread_settings: ThreadSettingsSnapshot {
model: snapshot.model,
model_provider_id: snapshot.model_provider_id,
service_tier: snapshot.service_tier,
approval_policy: snapshot.approval_policy,
approvals_reviewer: snapshot.approvals_reviewer,
permission_profile: snapshot.permission_profile,
active_permission_profile: snapshot.active_permission_profile,
cwd: snapshot.cwd,
reasoning_effort: snapshot.reasoning_effort,
reasoning_summary: snapshot.reasoning_summary,
personality: snapshot.personality,
collaboration_mode: snapshot.collaboration_mode,
},
})
}
pub(super) async fn user_input_or_turn_inner(
sess: &Arc<Session>,
sub_id: String,
op: Op,
mirror_user_text_to_realtime: Option<()>,
) {
let (items, updates, responsesapi_client_metadata) = match op {
Op::UserTurn {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
model,
effort,
summary,
service_tier,
final_output_json_schema,
items,
collaboration_mode,
personality,
environments,
} => {
let collaboration_mode = collaboration_mode.or_else(|| {
Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: model.clone(),
reasoning_effort: effort,
developer_instructions: None,
},
})
});
(
items,
SessionSettingsUpdate {
cwd: Some(cwd),
approval_policy: Some(approval_policy),
approvals_reviewer,
sandbox_policy: Some(sandbox_policy),
workspace_roots: None,
profile_workspace_roots: None,
permission_profile,
active_permission_profile: None,
windows_sandbox_level: None,
collaboration_mode,
reasoning_summary: summary,
service_tier,
final_output_json_schema: Some(final_output_json_schema),
environments,
personality,
app_server_client_name: None,
app_server_client_version: None,
},
None,
)
}
Op::UserInputWithTurnContext {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
final_output_json_schema,
items,
responsesapi_client_metadata,
collaboration_mode,
personality,
environments,
} => {
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
Some(collab_mode)
} else {
let state = sess.state.lock().await;
Some(
state
.session_configuration
.collaboration_mode
.with_updates(model, effort, /*developer_instructions*/ None),
)
};
(
items,
SessionSettingsUpdate {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
collaboration_mode,
reasoning_summary: summary,
service_tier,
final_output_json_schema: Some(final_output_json_schema),
environments,
personality,
app_server_client_name: None,
app_server_client_version: None,
},
responsesapi_client_metadata,
)
}
Op::UserInput {
items,
environments,
final_output_json_schema,
responsesapi_client_metadata,
} => (
items,
SessionSettingsUpdate {
final_output_json_schema: Some(final_output_json_schema),
environments,
..Default::default()
},
responsesapi_client_metadata,
),
_ => unreachable!(),
let Op::UserInput {
items,
environments,
final_output_json_schema,
responsesapi_client_metadata,
thread_settings,
} = op
else {
unreachable!();
};
let emit_thread_settings_applied = thread_settings != ThreadSettingsOverrides::default();
let mut updates = if emit_thread_settings_applied {
thread_settings_update(sess, thread_settings).await
} else {
SessionSettingsUpdate::default()
};
updates.final_output_json_schema = Some(final_output_json_schema);
updates.environments = environments;
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
// new_turn_with_sub_id already emits the error event.
return;
};
if emit_thread_settings_applied {
sess.send_event_raw(Event {
id: sub_id.clone(),
msg: thread_settings_applied_event(sess).await,
})
.await;
}
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
.await;
let accepted_items = match sess
@@ -319,7 +298,9 @@ pub async fn inter_agent_communication(
communication: InterAgentCommunication,
) {
let trigger_turn = communication.trigger_turn;
sess.enqueue_mailbox_communication(communication);
sess.input_queue
.enqueue_mailbox_communication(communication)
.await;
if trigger_turn {
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
.await;
@@ -479,16 +460,8 @@ pub async fn reload_user_config(sess: &Arc<Session>) {
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(
Arc::clone(&turn_context),
vec![UserInput::Text {
text: turn_context.compact_prompt().to_string(),
// Compaction prompt is synthesized; no UI element ranges to preserve.
text_elements: Vec::new(),
}],
CompactTask,
)
.await;
sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask)
.await;
}
pub async fn thread_rollback(sess: &Arc<Session>, sub_id: String, num_turns: u32) {
@@ -638,12 +611,14 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
sess.guardian_review_session.shutdown().await;
}
fn emit_thread_stop_lifecycle(sess: &Session) {
async fn emit_thread_stop_lifecycle(sess: &Session) {
for contributor in sess.services.extensions.thread_lifecycle_contributors() {
contributor.on_thread_stop(codex_extension_api::ThreadStopInput {
session_store: &sess.services.session_extension_data,
thread_store: &sess.services.thread_extension_data,
});
contributor
.on_thread_stop(codex_extension_api::ThreadStopInput {
session_store: &sess.services.session_extension_data,
thread_store: &sess.services.thread_extension_data,
})
.await;
}
}
@@ -662,7 +637,7 @@ pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
&[],
);
emit_thread_stop_lifecycle(sess.as_ref());
emit_thread_stop_lifecycle(sess.as_ref()).await;
// Gracefully flush and shutdown thread persistence on session end so tests
// that inspect durable state do not race with the background writer.
@@ -781,54 +756,12 @@ pub(super) async fn submission_loop(
realtime_conversation_list_voices(&sess, sub.id.clone()).await;
false
}
Op::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} => {
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
collab_mode
} else {
let state = sess.state.lock().await;
state.session_configuration.collaboration_mode.with_updates(
model.clone(),
effort,
/*developer_instructions*/ None,
)
};
override_turn_context(
&sess,
sub.id.clone(),
SessionSettingsUpdate {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
windows_sandbox_level,
collaboration_mode: Some(collaboration_mode),
reasoning_summary: summary,
service_tier,
personality,
..Default::default()
},
)
.await;
Op::UserInput { .. } => {
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
false
}
Op::UserInput { .. }
| Op::UserInputWithTurnContext { .. }
| Op::UserTurn { .. } => {
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
Op::ThreadSettings { thread_settings } => {
update_thread_settings(&sess, sub.id.clone(), thread_settings).await;
false
}
Op::InterAgentCommunication { communication } => {
@@ -917,7 +850,7 @@ pub(super) async fn submission_loop(
// explicit shutdown op, still run session teardown.
if !shutdown_received {
shutdown_session_runtime(&sess).await;
emit_thread_stop_lifecycle(sess.as_ref());
emit_thread_stop_lifecycle(sess.as_ref()).await;
}
debug!("Agent loop exited");
}
@@ -959,7 +892,9 @@ Approved action:
}];
if let Err(items) = sess.inject_response_items(items).await {
sess.queue_response_items_for_next_turn(items).await;
sess.input_queue
.queue_response_items_for_next_turn(items)
.await;
}
}
+379
View File
@@ -0,0 +1,379 @@
use crate::state::ActiveTurn;
use crate::state::MailboxDeliveryPhase;
use crate::state::TurnState;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::user_input::UserInput;
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::watch;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum TurnInput {
UserInput(Vec<UserInput>),
ResponseInputItem(ResponseInputItem),
}
/// Turn-local pending input storage owned by the input queue flow.
#[derive(Default)]
pub(crate) struct TurnInputQueue {
items: Vec<TurnInput>,
}
/// Session-scoped pending input storage and active-turn mailbox delivery coordination.
pub(crate) struct InputQueue {
mailbox_tx: watch::Sender<()>,
mailbox_pending_mails: Mutex<VecDeque<InterAgentCommunication>>,
idle_pending_input: Mutex<Vec<ResponseInputItem>>,
}
impl InputQueue {
pub(crate) fn new() -> Self {
let (mailbox_tx, _) = watch::channel(());
Self {
mailbox_tx,
mailbox_pending_mails: Mutex::new(VecDeque::new()),
idle_pending_input: Mutex::new(Vec::new()),
}
}
pub(crate) async fn subscribe_mailbox(&self) -> watch::Receiver<()> {
let mut mailbox_rx = self.mailbox_tx.subscribe();
if self.has_pending_mailbox_items().await {
mailbox_rx.mark_changed();
}
mailbox_rx
}
pub(crate) async fn enqueue_mailbox_communication(
&self,
communication: InterAgentCommunication,
) {
self.mailbox_pending_mails
.lock()
.await
.push_back(communication);
self.mailbox_tx.send_replace(());
}
pub(crate) async fn has_pending_mailbox_items(&self) -> bool {
!self.mailbox_pending_mails.lock().await.is_empty()
}
pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool {
self.mailbox_pending_mails
.lock()
.await
.iter()
.any(|mail| mail.trigger_turn)
}
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<ResponseInputItem> {
self.mailbox_pending_mails
.lock()
.await
.drain(..)
.map(|mail| mail.to_response_input_item())
.collect()
}
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
if items.is_empty() {
return;
}
self.idle_pending_input.lock().await.extend(items);
}
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
std::mem::take(&mut *self.idle_pending_input.lock().await)
}
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
!self.idle_pending_input.lock().await.is_empty()
}
pub(crate) async fn turn_state_for_sub_id(
&self,
active_turn: &Mutex<Option<ActiveTurn>>,
sub_id: &str,
) -> Option<Arc<Mutex<TurnState>>> {
let active = active_turn.lock().await;
active.as_ref().and_then(|active_turn| {
active_turn
.task
.as_ref()
.is_some_and(|task| task.turn_context.sub_id == sub_id)
.then(|| Arc::clone(&active_turn.turn_state))
})
}
/// Clear any pending waiters and input buffered for the current turn.
pub(crate) async fn clear_pending(&self, active_turn: &ActiveTurn) {
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.clear_pending_waiters();
turn_state.pending_input.items.clear();
}
pub(crate) async fn defer_mailbox_delivery_to_next_turn(
&self,
active_turn: &Mutex<Option<ActiveTurn>>,
sub_id: &str,
) {
let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await;
let Some(turn_state) = turn_state else {
return;
};
let mut turn_state = turn_state.lock().await;
if !turn_state.pending_input.items.is_empty() {
return;
}
turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn);
}
pub(crate) async fn accept_mailbox_delivery_for_current_turn(
&self,
active_turn: &Mutex<Option<ActiveTurn>>,
sub_id: &str,
) {
let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await;
let Some(turn_state) = turn_state else {
return;
};
self.accept_mailbox_delivery_for_turn_state(turn_state.as_ref())
.await;
}
pub(super) async fn accept_mailbox_delivery_for_turn_state(
&self,
turn_state: &Mutex<TurnState>,
) {
turn_state
.lock()
.await
.accept_mailbox_delivery_for_current_turn();
}
pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state(
&self,
turn_state: &Mutex<TurnState>,
input: TurnInput,
) {
let mut turn_state = turn_state.lock().await;
turn_state.pending_input.items.push(input);
turn_state.accept_mailbox_delivery_for_current_turn();
}
pub(crate) async fn extend_pending_input_for_turn_state(
&self,
turn_state: &Mutex<TurnState>,
input: Vec<TurnInput>,
) {
turn_state.lock().await.pending_input.items.extend(input);
}
pub(crate) async fn take_pending_input_for_turn_state(
&self,
turn_state: &Mutex<TurnState>,
) -> Vec<TurnInput> {
turn_state.lock().await.pending_input.items.split_off(0)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub(crate) async fn inject_response_items(
&self,
active_turn: &Mutex<Option<ActiveTurn>>,
input: Vec<ResponseInputItem>,
) -> Result<(), Vec<ResponseInputItem>> {
let mut active = active_turn.lock().await;
match active.as_mut() {
Some(active_turn) => {
self.extend_pending_input_for_turn_state(
active_turn.turn_state.as_ref(),
input
.into_iter()
.map(TurnInput::ResponseInputItem)
.collect(),
)
.await;
Ok(())
}
None => Err(input),
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub(crate) async fn get_pending_input(
&self,
active_turn: &Mutex<Option<ActiveTurn>>,
) -> Vec<TurnInput> {
let (pending_input, accepts_mailbox_delivery) = {
let mut active = active_turn.lock().await;
match active.as_mut() {
Some(active_turn) => {
let mut turn_state = active_turn.turn_state.lock().await;
(
turn_state.pending_input.items.split_off(0),
turn_state.accepts_mailbox_delivery_for_current_turn(),
)
}
None => (Vec::new(), true),
}
};
if !accepts_mailbox_delivery {
return pending_input;
}
let mailbox_items = self
.drain_mailbox_input_items()
.await
.into_iter()
.map(TurnInput::ResponseInputItem);
if pending_input.is_empty() {
mailbox_items.collect()
} else {
let mut pending_input = pending_input;
pending_input.extend(mailbox_items);
pending_input
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state reads must remain atomic"
)]
pub(crate) async fn has_pending_input(&self, active_turn: &Mutex<Option<ActiveTurn>>) -> bool {
let (has_turn_pending_input, accepts_mailbox_delivery) = {
let active = active_turn.lock().await;
match active.as_ref() {
Some(active_turn) => {
let turn_state = active_turn.turn_state.lock().await;
(
!turn_state.pending_input.items.is_empty(),
turn_state.accepts_mailbox_delivery_for_current_turn(),
)
}
None => (false, true),
}
};
if has_turn_pending_input {
return true;
}
if !accepts_mailbox_delivery {
return false;
}
self.has_pending_mailbox_items().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::AgentPath;
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 input_queue_notifies_mailbox_subscribers() {
let input_queue = InputQueue::new();
let mut mailbox_rx = input_queue.subscribe_mailbox().await;
input_queue
.enqueue_mailbox_communication(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"one",
/*trigger_turn*/ false,
))
.await;
input_queue
.enqueue_mailbox_communication(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"two",
/*trigger_turn*/ false,
))
.await;
mailbox_rx.changed().await.expect("mailbox update");
}
#[tokio::test]
async fn input_queue_drains_mailbox_in_delivery_order() {
let input_queue = InputQueue::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,
);
input_queue
.enqueue_mailbox_communication(mail_one.clone())
.await;
input_queue
.enqueue_mailbox_communication(mail_two.clone())
.await;
assert_eq!(
input_queue.drain_mailbox_input_items().await,
vec![
mail_one.to_response_input_item(),
mail_two.to_response_input_item()
]
);
assert!(!input_queue.has_pending_mailbox_items().await);
}
#[tokio::test]
async fn input_queue_tracks_pending_trigger_turn_mail() {
let input_queue = InputQueue::new();
input_queue
.enqueue_mailbox_communication(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"queued",
/*trigger_turn*/ false,
))
.await;
assert!(!input_queue.has_trigger_turn_mailbox_items().await);
input_queue
.enqueue_mailbox_communication(make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"wake",
/*trigger_turn*/ true,
))
.await;
assert!(input_queue.has_trigger_turn_mailbox_items().await);
}
}
+78 -17
View File
@@ -5,6 +5,7 @@ use codex_mcp::ElicitationReviewerHandle;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION;
use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY as MCP_ELICITATION_APPROVALS_REVIEWER_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_ELICITATION_CONNECTOR_ID_KEY;
@@ -21,6 +22,10 @@ use rmcp::model::Meta;
use serde_json::Map;
const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message";
const TOOL_SUGGESTION_ACTION_INSTALL: &str = "install";
const TOOL_SUGGESTION_ACTION_KEY: &str = "suggest_type";
const TOOL_SUGGESTION_TOOL_ID_KEY: &str = "tool_id";
const TOOL_SUGGESTION_TOOL_TYPE_KEY: &str = "tool_type";
#[derive(Debug, PartialEq)]
enum GuardianElicitationReview {
@@ -33,6 +38,18 @@ struct GuardianMcpElicitationReviewer {
session: std::sync::Weak<Session>,
}
pub(crate) struct McpServerElicitationOutcome {
pub(crate) response: Option<ElicitationResponse>,
pub(crate) sent: bool,
}
#[derive(Debug, PartialEq, Eq)]
struct PluginInstallElicitationTelemetryMetadata {
tool_type: String,
tool_id: String,
tool_name: String,
}
impl GuardianMcpElicitationReviewer {
fn new(session: &Arc<Session>) -> Self {
Self {
@@ -70,7 +87,7 @@ impl Session {
turn_context: &TurnContext,
request_id: RequestId,
params: McpServerElicitationRequestParams,
) -> Option<ElicitationResponse> {
) -> McpServerElicitationOutcome {
if self
.services
.mcp_connection_manager
@@ -78,11 +95,14 @@ impl Session {
.await
.elicitations_auto_deny()
{
return Some(ElicitationResponse {
action: codex_rmcp_client::ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
});
return McpServerElicitationOutcome {
response: Some(ElicitationResponse {
action: codex_rmcp_client::ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
}),
sent: false,
};
}
let server_name = params.server_name.clone();
@@ -98,7 +118,10 @@ impl Session {
warn!(
"failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}"
);
return None;
return McpServerElicitationOutcome {
response: None,
sent: false,
};
}
};
codex_protocol::approvals::ElicitationRequest::Form {
@@ -154,11 +177,24 @@ impl Session {
id,
request,
});
let plugin_install_telemetry = plugin_install_elicitation_telemetry_metadata(&event);
turn_context
.turn_metadata_state
.mark_user_input_requested_during_turn();
self.send_event(turn_context, event).await;
rx_response.await.ok()
if let Some(plugin_install_telemetry) = plugin_install_telemetry {
turn_context
.session_telemetry
.record_plugin_install_elicitation_sent(
plugin_install_telemetry.tool_type.as_str(),
plugin_install_telemetry.tool_id.as_str(),
plugin_install_telemetry.tool_name.as_str(),
);
}
McpServerElicitationOutcome {
response: rx_response.await.ok(),
sent: true,
}
}
#[expect(
@@ -289,16 +325,13 @@ impl Session {
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeEnvironment::new(
Arc::clone(&turn_environment.environment),
let mcp_runtime_context = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
self.services
.environment_manager
.default_environment()
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
None => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
#[allow(deprecated)]
turn_context.cwd.to_path_buf(),
),
@@ -316,10 +349,11 @@ impl Session {
turn_context.sub_id.clone(),
self.get_tx_event(),
turn_context.permission_profile(),
mcp_runtime_environment,
mcp_runtime_context,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
tool_plugin_provenance,
auth.as_ref(),
@@ -550,6 +584,33 @@ fn metadata_owned_string(meta: &Map<String, Value>, key: &str) -> Option<String>
.map(ToOwned::to_owned)
}
fn plugin_install_elicitation_telemetry_metadata(
event: &EventMsg,
) -> Option<PluginInstallElicitationTelemetryMetadata> {
let EventMsg::ElicitationRequest(ElicitationRequestEvent { request, .. }) = event else {
return None;
};
let codex_protocol::approvals::ElicitationRequest::Form {
meta: Some(Value::Object(meta)),
..
} = request
else {
return None;
};
if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY)
!= Some(MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION)
|| metadata_str(meta, TOOL_SUGGESTION_ACTION_KEY) != Some(TOOL_SUGGESTION_ACTION_INSTALL)
{
return None;
}
Some(PluginInstallElicitationTelemetryMetadata {
tool_type: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_TYPE_KEY)?,
tool_id: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_ID_KEY)?,
tool_name: metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY)?,
})
}
fn mcp_elicitation_request_id(id: &RequestId) -> String {
match id {
rmcp::model::NumberOrString::String(value) => value.to_string(),
+57
View File
@@ -96,6 +96,63 @@ fn guardian_elicitation_review_request_defaults_missing_tool_params() {
assert_eq!(arguments, Some(json!({})));
}
#[test]
fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestion() {
let event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "codex_apps".to_string(),
id: codex_protocol::mcp::RequestId::String("request-1".to_string()),
request: codex_protocol::approvals::ElicitationRequest::Form {
meta: Some(json!({
"codex_approval_kind": "tool_suggestion",
"suggest_type": "install",
"tool_type": "plugin",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
message: "Install Slack?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
});
assert_eq!(
plugin_install_elicitation_telemetry_metadata(&event),
Some(PluginInstallElicitationTelemetryMetadata {
tool_type: "plugin".to_string(),
tool_id: "slack@openai-curated".to_string(),
tool_name: "Slack".to_string(),
})
);
let enable_event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "codex_apps".to_string(),
id: codex_protocol::mcp::RequestId::String("request-2".to_string()),
request: codex_protocol::approvals::ElicitationRequest::Form {
meta: Some(json!({
"codex_approval_kind": "tool_suggestion",
"suggest_type": "enable",
"tool_type": "plugin",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
message: "Enable Slack?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
});
assert_eq!(
plugin_install_elicitation_telemetry_metadata(&enable_event),
None
);
}
#[test]
fn guardian_elicitation_review_request_requires_opt_in() {
let request = form_request(meta(json!({
+159 -297
View File
@@ -10,8 +10,6 @@ use std::time::UNIX_EPOCH;
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::attestation::AttestationProvider;
@@ -63,7 +61,7 @@ use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::codex_apps_tools_cache_key;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
@@ -74,13 +72,14 @@ use codex_otel::current_span_trace_id;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
@@ -95,6 +94,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileChange;
@@ -173,6 +173,7 @@ use crate::compact::collect_user_messages;
use crate::config::Config;
use crate::config::Constrained;
use crate::config::ConstraintResult;
use crate::config::PermissionProfileSnapshot;
use crate::config::PermissionProfileState;
use crate::config::StartedNetworkProxy;
use crate::config::resolve_web_search_mode_for_turn;
@@ -192,6 +193,7 @@ use codex_protocol::exec_output::StreamOutput;
mod config_lock;
mod handlers;
mod input_queue;
mod mcp;
mod multi_agents;
mod review;
@@ -205,6 +207,8 @@ use self::config_lock::validate_config_lock_if_configured;
#[cfg(test)]
use self::handlers::submission_dispatch_span;
use self::handlers::submission_loop;
pub(crate) use self::input_queue::TurnInput;
pub(crate) use self::input_queue::TurnInputQueue;
use self::review::spawn_review_thread;
use self::session::AppServerClientMetadata;
use self::session::Session;
@@ -214,8 +218,6 @@ pub(crate) use self::session::SessionSettingsUpdate;
use self::turn::AssistantMessageStreamParsers;
#[cfg(test)]
use self::turn::collect_explicit_app_ids_from_skill_items;
#[cfg(test)]
use self::turn::filter_connectors_for_input;
use self::turn::realtime_text_for_event;
use self::turn_context::TurnContext;
use self::turn_context::TurnSkillsContext;
@@ -288,8 +290,7 @@ use crate::rollout::map_session_init_error;
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
use crate::shell;
use crate::shell_snapshot::ShellSnapshot;
use crate::state::ActiveTurn;
use crate::state::MailboxDeliveryPhase;
use crate::state::AutoCompactWindowSnapshot;
use crate::state::PendingRequestPermissions;
use crate::state::SessionServices;
use crate::state::SessionState;
@@ -319,7 +320,6 @@ use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
@@ -358,8 +358,8 @@ use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolEnvironmentMode;
use codex_tools::ToolsConfig;
use codex_tools::ToolsConfigParams;
use codex_tools::UnifiedExecShellMode;
use codex_tools::shell_command_backend_for_features;
use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(test)]
use codex_utils_stream_parser::ProposedPlanSegment;
@@ -479,20 +479,6 @@ impl Codex {
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
let fs = environment_selections.primary_filesystem();
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await;
for err in &loaded_skills.errors {
error!(
"failed to load skill {}: {}",
err.path.display(),
err.message
);
}
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source
&& depth >= config.agent_max_depth
@@ -503,9 +489,14 @@ impl Codex {
}
let primary_environment = environment_selections.primary_environment();
let mut user_instruction_warnings = Vec::new();
let user_instructions = AgentsMdManager::new(&config)
.user_instructions(primary_environment.as_deref())
.user_instructions(
primary_environment.as_deref(),
&mut user_instruction_warnings,
)
.await;
config.startup_warnings.extend(user_instruction_warnings);
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
// Guardian review should rely on the built-in shell safety checks,
@@ -597,14 +588,10 @@ impl Codex {
developer_instructions: None,
},
};
let account_plan_type = auth_manager
.auth_cached()
.and_then(|auth| auth.account_plan_type());
let service_tier = get_service_tier(
config.service_tier.clone(),
config.notices.fast_default_opt_out.unwrap_or(false),
account_plan_type,
config.features.enabled(Feature::FastMode),
&model_info,
);
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
@@ -806,17 +793,16 @@ impl Codex {
fn get_service_tier(
configured_service_tier: Option<String>,
fast_default_opt_out: bool,
account_plan_type: Option<AccountPlanType>,
fast_mode_enabled: bool,
model_info: &ModelInfo,
) -> Option<String> {
if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled {
return configured_service_tier;
if !fast_mode_enabled {
return None;
}
account_plan_type
.is_some_and(is_enterprise_default_service_tier_plan)
.then_some(ServiceTier::Fast.request_value().to_string())
configured_service_tier.filter(|service_tier| {
service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE
|| model_info.supports_service_tier(service_tier)
})
}
fn session_permission_profile_state_from_config(
@@ -825,12 +811,6 @@ fn session_permission_profile_state_from_config(
Ok(config.permissions.permission_profile_state().clone())
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
plan_type == AccountPlanType::Enterprise
|| plan_type.is_business_like()
|| plan_type.is_team_like()
}
#[cfg(test)]
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
futures::future::ready(()).boxed().shared()
@@ -898,22 +878,6 @@ impl Session {
}
}
pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec<String> {
if !self.features.enabled(Feature::MultiAgentV2) {
return Vec::new();
}
let state = self.state.lock().await;
let config = &state.session_configuration.original_config_do_not_use;
[
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
config.multi_agent_v2.subagent_usage_hint_text.clone(),
]
.into_iter()
.flatten()
.collect()
}
fn managed_network_proxy_active_for_permission_profile(
permission_profile: &PermissionProfile,
) -> bool {
@@ -994,9 +958,6 @@ impl Session {
}
async fn refresh_managed_network_proxy_for_current_permission_profile(&self) {
let Some(started_proxy) = self.services.network_proxy.as_ref() else {
return;
};
let Ok(_refresh_guard) = self.managed_network_proxy_refresh_lock.acquire().await else {
error!("managed network proxy refresh semaphore closed");
return;
@@ -1010,7 +971,9 @@ impl Session {
.permissions
.network
.as_ref()
.cloned()
else {
self.services.network_proxy.store(None);
return;
};
@@ -1033,8 +996,36 @@ impl Session {
spec
}
};
if let Err(err) = spec.apply_to_started_proxy(started_proxy).await {
warn!("failed to refresh managed network proxy for sandbox change: {err}");
if let Some(started_proxy) = self.services.network_proxy.load_full() {
if let Err(err) = spec.apply_to_started_proxy(started_proxy.as_ref()).await {
warn!("failed to refresh managed network proxy for sandbox change: {err}");
}
return;
}
match Self::start_managed_network_proxy(
&spec,
current_exec_policy.as_ref(),
&session_configuration.permission_profile(),
/*network_policy_decider*/ None,
self.services
.managed_network_requirements_configured
.then(|| {
build_blocked_request_observer(Arc::clone(&self.services.network_approval))
}),
self.services.managed_network_requirements_configured,
self.services.network_proxy_audit_metadata.clone(),
)
.await
{
Ok((started_proxy, _session_network_proxy)) => {
self.services
.network_proxy
.store(Some(Arc::new(started_proxy)));
}
Err(err) => {
warn!("failed to start managed network proxy for sandbox change: {err}");
}
}
}
@@ -1113,6 +1104,7 @@ impl Session {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
thread_settings: Default::default(),
},
/*mirror_user_text_to_realtime*/ None,
)
@@ -1124,6 +1116,11 @@ impl Session {
state.get_total_token_usage(state.server_reasoning_included())
}
pub(crate) async fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
let state = self.state.lock().await;
state.auto_compact_window_snapshot()
}
pub(crate) async fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown {
let state = self.state.lock().await;
state.history.get_total_token_usage_breakdown()
@@ -1282,11 +1279,41 @@ impl Session {
reconstructed_rollout.reference_context_item,
)
.await;
let prefix_tokens = if matches!(
turn_context.config.model_auto_compact_token_limit_scope,
AutoCompactTokenLimitScope::BodyAfterPrefix
) {
let history = self.clone_history().await;
let base_instructions = self.get_base_instructions().await;
history.estimate_token_count_with_base_instructions(&base_instructions)
} else {
None
};
if let Some(prefix_tokens) = prefix_tokens {
self.set_auto_compact_window_estimated_prefill_for_scope(turn_context, prefix_tokens)
.await;
}
self.set_previous_turn_settings(previous_turn_settings.clone())
.await;
previous_turn_settings
}
async fn set_auto_compact_window_estimated_prefill_for_scope(
&self,
turn_context: &TurnContext,
tokens: i64,
) {
if !matches!(
turn_context.config.model_auto_compact_token_limit_scope,
AutoCompactTokenLimitScope::BodyAfterPrefix
) {
return;
}
let mut state = self.state.lock().await;
state.set_auto_compact_window_estimated_prefill(tokens);
}
fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option<TokenUsageInfo> {
rollout_items.iter().rev().find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(),
@@ -1402,12 +1429,15 @@ impl Session {
Ok(())
}
pub(crate) async fn validate_settings(
pub(crate) async fn preview_settings(
&self,
updates: &SessionSettingsUpdate,
) -> ConstraintResult<()> {
) -> ConstraintResult<ThreadConfigSnapshot> {
let state = self.state.lock().await;
state.session_configuration.apply(updates).map(|_| ())
state
.session_configuration
.apply(updates)
.map(|configuration| configuration.thread_config_snapshot())
}
pub(crate) async fn set_session_startup_prewarm(
@@ -1820,7 +1850,8 @@ impl Session {
let active = self.active_turn.lock().await;
active
.as_ref()
.and_then(|turn| turn.tasks.get(sub_id))
.and_then(|turn| turn.task.as_ref())
.filter(|task| task.turn_context.sub_id == sub_id)
.map(|task| Arc::clone(&task.turn_context))
}
@@ -1828,7 +1859,7 @@ impl Session {
&self,
) -> Option<(Arc<TurnContext>, CancellationToken)> {
let active = self.active_turn.lock().await;
let (_, task) = active.as_ref()?.tasks.first()?;
let task = active.as_ref()?.task.as_ref()?;
Some((
Arc::clone(&task.turn_context),
task.cancellation_token.child_token(),
@@ -1889,7 +1920,7 @@ impl Session {
let execpolicy_amendment =
execpolicy_network_rule_amendment(amendment, network_approval_context, &host);
if let Some(started_network_proxy) = self.services.network_proxy.as_ref() {
if let Some(started_network_proxy) = self.services.network_proxy.load_full() {
let proxy = started_network_proxy.proxy();
match amendment.action {
NetworkPolicyRuleAction::Allow => proxy
@@ -2600,8 +2631,11 @@ impl Session {
reference_context_item: Option<TurnContextItem>,
compacted_item: CompactedItem,
) {
self.replace_history(items, reference_context_item.clone())
.await;
{
let mut state = self.state.lock().await;
state.replace_history(items, reference_context_item.clone());
state.start_next_auto_compact_window();
}
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
.await;
@@ -2609,6 +2643,10 @@ impl Session {
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)])
.await;
}
{
let mut state = self.state.lock().await;
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
}
self.services.model_client.advance_window_generation();
}
@@ -2708,14 +2746,6 @@ impl Session {
{
developer_sections.push(developer_instructions.to_string());
}
// Add developer instructions for memories.
if turn_context.features.enabled(Feature::MemoryTool)
&& turn_context.config.memories.use_memories
&& let Some(memory_prompt) =
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
{
developer_sections.push(memory_prompt);
}
// Add developer instructions from collaboration_mode if they exist and are non-empty
if turn_context.config.include_collaboration_mode_instructions
&& let Some(collab_instructions) =
@@ -2967,16 +2997,24 @@ impl Session {
let mut state = self.state.lock().await;
state
.update_token_info_from_usage(token_usage, turn_context.model_context_window());
if matches!(
turn_context.config.model_auto_compact_token_limit_scope,
AutoCompactTokenLimitScope::BodyAfterPrefix
) {
state.ensure_auto_compact_window_server_prefill_from_usage(token_usage);
}
state.token_info()
};
if let Some(token_info) = token_info.as_ref() {
for contributor in self.services.extensions.token_usage_contributors() {
contributor.on_token_usage(
&self.services.session_extension_data,
&self.services.thread_extension_data,
turn_context.extension_data.as_ref(),
token_info,
);
contributor
.on_token_usage(
&self.services.session_extension_data,
&self.services.thread_extension_data,
turn_context.extension_data.as_ref(),
token_info,
)
.await;
}
}
}
@@ -3012,6 +3050,11 @@ impl Session {
state.set_token_info(Some(info));
}
self.set_auto_compact_window_estimated_prefill_for_scope(
turn_context,
estimated_total_tokens,
)
.await;
self.send_token_count_event(turn_context).await;
}
@@ -3044,16 +3087,6 @@ impl Session {
state.record_mcp_dependency_prompted(names);
}
pub async fn dependency_env(&self) -> HashMap<String, String> {
let state = self.state.lock().await;
state.dependency_env()
}
pub async fn set_dependency_env(&self, values: HashMap<String, String>) {
let mut state = self.state.lock().await;
state.set_dependency_env(values);
}
pub(crate) async fn set_server_reasoning_included(&self, included: bool) {
let mut state = self.state.lock().await;
state.set_server_reasoning_included(included);
@@ -3096,11 +3129,11 @@ impl Session {
&self,
turn_context: &TurnContext,
input: &[UserInput],
response_item: ResponseItem,
) {
// Persist the user message to history, but emit the turn item from `UserInput` so
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
let response_item = ResponseItem::from(ResponseInputItem::from(input.to_vec()));
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
let turn_item = TurnItem::UserMessage(UserMessageItem::new(input));
@@ -3140,18 +3173,15 @@ impl Session {
expected_turn_id: Option<&str>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
) -> Result<String, SteerInputError> {
if input.is_empty() {
return Err(SteerInputError::EmptyInput);
}
let mut active = self.active_turn.lock().await;
let Some(active_turn) = active.as_mut() else {
return Err(SteerInputError::NoActiveTurn(input));
};
let Some((active_turn_id, _)) = active_turn.tasks.first() else {
let Some(active_task) = active_turn.task.as_ref() else {
return Err(SteerInputError::NoActiveTurn(input));
};
let active_turn_id = &active_task.turn_context.sub_id;
if let Some(expected_turn_id) = expected_turn_id
&& expected_turn_id != active_turn_id
@@ -3162,223 +3192,64 @@ impl Session {
});
}
match active_turn.tasks.first().map(|(_, task)| task.kind) {
Some(crate::state::TaskKind::Regular) => {}
Some(crate::state::TaskKind::Review) => {
match active_task.kind {
crate::state::TaskKind::Regular => {}
crate::state::TaskKind::Review => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
});
}
Some(crate::state::TaskKind::Compact) => {
crate::state::TaskKind::Compact => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
});
}
None => return Err(SteerInputError::NoActiveTurn(input)),
}
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata
&& let Some((_, active_task)) = active_turn.tasks.first()
{
if input.is_empty() {
return Err(SteerInputError::EmptyInput);
}
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
active_task
.turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.push_pending_input(input.into());
turn_state.accept_mailbox_delivery_for_current_turn();
self.input_queue
.push_pending_input_and_accept_mailbox_delivery_for_turn_state(
active_turn.turn_state.as_ref(),
TurnInput::UserInput(input),
)
.await;
Ok(active_turn_id.clone())
}
/// Returns the input if there was no task running to inject into.
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn inject_response_items(
&self,
input: Vec<ResponseInputItem>,
) -> Result<(), Vec<ResponseInputItem>> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
for item in input {
ts.push_pending_input(item);
}
Ok(())
}
None => Err(input),
}
}
pub(crate) async fn defer_mailbox_delivery_to_next_turn(&self, sub_id: &str) {
let turn_state = self.turn_state_for_sub_id(sub_id).await;
let Some(turn_state) = turn_state else {
return;
};
let mut turn_state = turn_state.lock().await;
if turn_state.has_pending_input() {
return;
}
turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn);
}
pub(crate) async fn accept_mailbox_delivery_for_current_turn(&self, sub_id: &str) {
let turn_state = self.turn_state_for_sub_id(sub_id).await;
let Some(turn_state) = turn_state else {
return;
};
turn_state
.lock()
self.input_queue
.inject_response_items(&self.active_turn, input)
.await
.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn);
}
pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) {
let turn_state = self.turn_state_for_sub_id(sub_id).await;
let turn_state = self
.input_queue
.turn_state_for_sub_id(&self.active_turn, sub_id)
.await;
let Some(turn_state) = turn_state else {
return;
};
turn_state.lock().await.has_memory_citation = true;
}
async fn turn_state_for_sub_id(
&self,
sub_id: &str,
) -> Option<Arc<tokio::sync::Mutex<crate::state::TurnState>>> {
let active = self.active_turn.lock().await;
active.as_ref().and_then(|active_turn| {
active_turn
.tasks
.contains_key(sub_id)
.then(|| Arc::clone(&active_turn.turn_state))
})
}
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(crate) async fn has_pending_mailbox_items(&self) -> bool {
self.mailbox_rx.lock().await.has_pending()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn prepend_pending_input(&self, input: Vec<ResponseInputItem>) -> Result<(), ()> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.prepend_pending_input(input);
Ok(())
}
None => Err(()),
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
let (pending_input, accepts_mailbox_delivery) = {
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(),
ts.accepts_mailbox_delivery_for_current_turn(),
)
}
None => (Vec::new(), true),
}
};
if !accepts_mailbox_delivery {
return pending_input;
}
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.
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
if items.is_empty() {
return;
}
let mut idle_pending_input = self.idle_pending_input.lock().await;
idle_pending_input.extend(items);
}
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
std::mem::take(&mut *self.idle_pending_input.lock().await)
}
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
!self.idle_pending_input.lock().await.is_empty()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state reads must remain atomic"
)]
pub async fn has_pending_input(&self) -> bool {
let (has_turn_pending_input, accepts_mailbox_delivery) = {
let active = self.active_turn.lock().await;
match active.as_ref() {
Some(at) => {
let ts = at.turn_state.lock().await;
(
ts.has_pending_input(),
ts.accepts_mailbox_delivery_for_current_turn(),
)
}
None => (false, true),
}
};
if has_turn_pending_input {
return true;
}
if !accepts_mailbox_delivery {
return false;
}
self.has_pending_mailbox_items().await
}
pub async fn interrupt_task(self: &Arc<Self>) {
info!("interrupt received: abort current task, if any");
let had_active_turn = self.active_turn.lock().await.is_some();
// Even without an active task, interrupt handling pauses any active goal.
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
if !had_active_turn {
self.cancel_mcp_startup().await;
@@ -3456,8 +3327,6 @@ pub(crate) fn emit_subagent_session_started(
});
}
use codex_memories_read::build_memory_tool_developer_instructions;
/// Builds the hook engine for one config snapshot, including any enabled plugin hooks.
async fn build_hooks_for_config(
config: &Config,
@@ -3467,17 +3336,10 @@ async fn build_hooks_for_config(
let mut hook_shell_argv = user_shell.derive_exec_args("", /*use_login_shell*/ false);
let hook_shell_program = hook_shell_argv.remove(0);
let _ = hook_shell_argv.pop();
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
let (plugin_hook_sources, plugin_hook_load_warnings) = if plugin_hooks_enabled {
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
(
plugin_outcome.effective_plugin_hook_sources(),
plugin_outcome.effective_plugin_hook_warnings(),
)
} else {
(Vec::new(), Vec::new())
};
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let plugin_hook_sources = plugin_outcome.effective_plugin_hook_sources();
let plugin_hook_load_warnings = plugin_outcome.effective_plugin_hook_warnings();
Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
feature_enabled: config.features.enabled(Feature::CodexHooks),
+15 -53
View File
@@ -1,4 +1,3 @@
use super::turn_context::image_generation_tool_auth_allowed;
use super::*;
use std::sync::atomic::AtomicBool;
@@ -23,60 +22,21 @@ pub(super) async fn spawn_review_thread(
let mut review_features = sess.features.clone();
let _ = review_features.disable(Feature::WebSearchRequest);
let _ = review_features.disable(Feature::WebSearchCached);
let _ = review_features.disable(Feature::Goals);
let review_web_search_mode = WebSearchMode::Disabled;
let goal_tools_supported = !config.ephemeral && parent_turn_context.tools_config.goal_tools;
let provider_capabilities = parent_turn_context.provider.capabilities();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &review_model_info,
available_models: &sess
.services
.models_manager
.list_models(RefreshStrategy::OnlineIfUncached)
.await,
features: &review_features,
image_generation_tool_auth_allowed: image_generation_tool_auth_allowed(Some(
sess.services.auth_manager.as_ref(),
)),
web_search_mode: Some(review_web_search_mode),
session_source: parent_turn_context.session_source.clone(),
permission_profile: &parent_turn_context.permission_profile,
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
})
.with_namespace_tools_capability(provider_capabilities.namespace_tools)
.with_image_generation_capability(provider_capabilities.image_generation)
.with_web_search_capability(provider_capabilities.web_search)
.with_unified_exec_shell_mode_for_session(
let goal_tools_supported = !config.ephemeral && parent_turn_context.goal_tools_enabled();
let available_models = sess
.services
.models_manager
.list_models(RefreshStrategy::OnlineIfUncached)
.await;
let shell_command_backend = shell_command_backend_for_features(review_features.get());
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
shell_command_backend,
crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()),
sess.services.shell_zsh_path.as_ref(),
sess.services.main_execve_wrapper_exe.as_ref(),
)
.with_web_search_config(/*web_search_config*/ None)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_environment_mode(parent_turn_context.tools_config.environment_mode)
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
.with_goal_tools_allowed(goal_tools_supported)
.with_max_concurrent_threads_per_session(config.agent_max_threads)
.with_wait_agent_min_timeout_ms(
review_features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.min_wait_timeout_ms),
)
.with_wait_agent_max_timeout_ms(
review_features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.max_wait_timeout_ms),
)
.with_wait_agent_default_timeout_ms(
review_features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.default_wait_timeout_ms),
)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
);
let review_prompt = resolved.prompt.clone();
let provider = parent_turn_context.provider.clone();
@@ -138,8 +98,10 @@ pub(super) async fn spawn_review_thread(
session_source,
thread_source: parent_turn_context.thread_source,
environments: parent_turn_context.environments.clone(),
tools_config,
features: parent_turn_context.features.clone(),
available_models,
unified_exec_shell_mode,
goal_tools_supported,
features: review_features,
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
current_date: parent_turn_context.current_date.clone(),
timezone: parent_turn_context.timezone.clone(),
@@ -59,7 +59,6 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -74,11 +73,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)];
@@ -101,7 +96,6 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
let previous_model = "previous-rollout-model";
let mut previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -116,11 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let turn_id = previous_context_item
.turn_id
@@ -132,6 +122,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -143,6 +134,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(previous_context_item),
@@ -198,6 +190,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -209,6 +202,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(first_context_item.clone()),
@@ -226,6 +220,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: rolled_back_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -237,6 +232,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(rolled_back_context_item),
@@ -296,6 +292,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -307,6 +304,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(first_context_item.clone()),
@@ -324,6 +322,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -335,6 +334,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::ResponseItem(turn_two_user),
@@ -386,6 +386,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -397,6 +398,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(first_context_item.clone()),
@@ -414,6 +416,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: second_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -425,6 +428,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::ResponseItem(turn_two_user),
@@ -441,6 +445,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: standalone_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -504,6 +509,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -515,6 +521,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(first_context_item.clone()),
@@ -532,6 +539,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: assistant_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -592,6 +600,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: only_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -603,6 +612,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(only_context_item),
@@ -644,6 +654,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: user_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -655,6 +666,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(previous_context_item),
@@ -671,6 +683,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: standalone_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -716,6 +729,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -727,6 +741,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(previous_context_item.clone()),
@@ -742,6 +757,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -753,6 +769,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::Compacted(CompactedItem {
@@ -873,6 +890,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -884,6 +902,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(current_context_item),
@@ -912,7 +931,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -927,11 +945,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
.turn_id
@@ -941,6 +955,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -952,6 +967,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
// Compaction clears baseline until a later TurnContextItem re-establishes it.
@@ -991,7 +1007,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
.expect("serialize seeded reference context item"),
serde_json::to_value(Some(TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -1006,11 +1021,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}))
.expect("serialize expected reference context item")
);
@@ -1023,7 +1034,6 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -1038,11 +1048,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
.turn_id
@@ -1054,6 +1060,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1065,6 +1072,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(previous_context_item),
@@ -1080,6 +1088,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: aborted_turn_id,
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1091,6 +1100,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::EventMsg(EventMsg::TurnAborted(
@@ -1139,7 +1149,6 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
let unmatched_abort_turn_id = "other-turn".to_string();
let current_context_item = TurnContextItem {
turn_id: Some(current_turn_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -1154,17 +1163,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let rollout_items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1176,6 +1182,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(previous_context_item),
@@ -1191,6 +1198,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1202,6 +1210,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::EventMsg(EventMsg::TurnAborted(
@@ -1254,7 +1263,6 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -1269,11 +1277,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
.turn_id
@@ -1285,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1296,6 +1301,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(previous_context_item),
@@ -1311,6 +1317,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1322,6 +1329,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::Compacted(CompactedItem {
@@ -1361,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id,
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1372,6 +1381,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(current_context_item.clone()),
@@ -1407,7 +1417,6 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
@@ -1422,11 +1431,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
.turn_id
@@ -1439,6 +1444,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1450,6 +1456,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::TurnContext(previous_context_item),
@@ -1465,6 +1472,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: compacted_incomplete_turn_id,
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1476,6 +1484,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
},
)),
RolloutItem::Compacted(CompactedItem {
@@ -1487,6 +1496,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: replacing_turn_id,
trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,

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