Merge remote-tracking branch 'origin/abhinav/subagent-stop-stack' into abhinav/subagent-hook-context-stack

# Conflicts:
#	codex-rs/core/src/hook_runtime.rs
#	codex-rs/core/tests/suite/subagent_notifications.rs
This commit is contained in:
Abhinav Vedmala
2026-05-20 10:14:44 -07:00
711 changed files with 31470 additions and 19977 deletions
+54 -7
View File
@@ -309,6 +309,25 @@
}
]
},
"AutoCompactTokenLimitScope": {
"description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.",
"oneOf": [
{
"description": "Count the full active context against the limit.",
"enum": [
"total"
],
"type": "string"
},
{
"description": "Count sampled output and later growth after the carried window prefix.",
"enum": [
"body_after_prefix"
],
"type": "string"
}
]
},
"AutoReviewToml": {
"properties": {
"policy": {
@@ -817,13 +836,23 @@
"type": "object"
},
"FileSystemAccessMode": {
"description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.",
"enum": [
"read",
"write",
"none"
],
"type": "string"
"description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `deny` beats `write`, and `write` beats `read`.",
"oneOf": [
{
"enum": [
"read",
"write"
],
"type": "string"
},
{
"description": "`none` is a legacy input alias retained temporarily for compatibility.",
"enum": [
"deny"
],
"type": "string"
}
]
},
"FilesystemPermissionToml": {
"anyOf": [
@@ -1530,6 +1559,12 @@
"subagent_usage_hint_text": {
"type": "string"
},
"tool_namespace": {
"maxLength": 64,
"minLength": 1,
"pattern": "^[a-zA-Z0-9_-]+$",
"type": "string"
},
"usage_hint_enabled": {
"type": "boolean"
},
@@ -4030,6 +4065,10 @@
"default": null,
"description": "Settings for app-specific controls."
},
"apps_mcp_product_sku": {
"description": "Optional product SKU forwarded on host-owned Codex Apps MCP requests.",
"type": "string"
},
"audio": {
"allOf": [
{
@@ -4548,6 +4587,14 @@
"format": "int64",
"type": "integer"
},
"model_auto_compact_token_limit_scope": {
"allOf": [
{
"$ref": "#/definitions/AutoCompactTokenLimitScope"
}
],
"description": "Controls whether the auto-compaction limit applies to the full context or only to tokens after the carried prefix in the current compaction window."
},
"model_catalog_json": {
"allOf": [
{
+82 -31
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()
{
@@ -116,16 +116,34 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| 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.
@@ -395,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(),
@@ -416,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(
@@ -498,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) => {
@@ -1234,7 +1283,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(),
+270 -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;
@@ -442,6 +443,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 +488,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 +597,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 +678,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 +737,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 +769,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 +790,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 +1024,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 +1088,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
-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;
+9 -5
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,13 +59,14 @@ 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>,
) -> 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) {
if let Ok(contents) = fs.read_file_text(&path, /*sandbox*/ None).await {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(LoadedAgentsMd {
@@ -128,9 +130,11 @@ 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 paths =
Self::load_global_instructions(LOCAL_FS.as_ref(), Some(&self.config.codex_home))
.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) => {
+1
View File
@@ -384,6 +384,7 @@ fn build_arc_monitor_message_item(
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => None,
}
+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?;
+35 -15
View File
@@ -59,9 +59,13 @@ pub struct ThreadConfigSnapshot {
pub permission_profile: PermissionProfile,
pub active_permission_profile: Option<ActivePermissionProfile>,
pub cwd: AbsolutePathBuf,
pub workspace_roots: Vec<AbsolutePathBuf>,
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>,
}
@@ -78,10 +82,12 @@ 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>>,
pub approval_policy: Option<AskForApproval>,
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub sandbox_policy: Option<SandboxPolicy>,
@@ -140,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
@@ -148,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;
}
}
@@ -251,13 +259,23 @@ 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,
approval_policy,
approvals_reviewer,
sandbox_policy,
@@ -281,8 +299,10 @@ impl CodexThread {
.with_updates(model, effort, /*developer_instructions*/ None)
};
let updates = SessionSettingsUpdate {
SessionSettingsUpdate {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
@@ -294,8 +314,7 @@ impl CodexThread {
service_tier,
personality,
..Default::default()
};
self.codex.session.validate_settings(&updates).await
}
}
/// Use sparingly: this is intended to be removed soon.
@@ -373,6 +392,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
View File
@@ -282,7 +282,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)
+1 -4
View File
@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::Prompt;
@@ -174,9 +173,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?;
@@ -302,6 +298,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } if role == "assistant" => true,
ResponseItem::Message { .. } => false,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger => false,
ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
+52 -49
View File
@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::Prompt;
@@ -12,6 +11,10 @@ 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::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::session::session::Session;
use crate::session::turn::built_tools;
use crate::session::turn_context::TurnContext;
@@ -97,6 +100,21 @@ async fn run_remote_compact_task_inner(
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 +122,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,16 +179,11 @@ 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?;
let mut input = prompt_input.clone();
input.push(ResponseItem::ContextCompaction {
encrypted_content: None,
});
input.push(ResponseItem::CompactionTrigger);
let prompt = Prompt {
input,
tools: tool_router.model_visible_specs(),
@@ -276,38 +292,25 @@ async fn run_remote_compaction_request_v2(
Err(err)
})
.await?;
collect_context_compaction_output(stream).await
collect_compaction_output(stream).await
}
async fn collect_context_compaction_output(
async fn collect_compaction_output(
mut stream: ResponseStream,
) -> CodexResult<(ResponseItem, String)> {
let mut output_item_count = 0usize;
let mut context_compaction_count = 0usize;
let mut context_compaction_output = None;
let mut compaction_count = 0usize;
let mut compaction_output = None;
let mut completed_response_id = None;
while let Some(event) = stream.next().await {
match event? {
ResponseEvent::OutputItemDone(item) => {
output_item_count += 1;
match item {
ResponseItem::ContextCompaction {
encrypted_content: Some(_),
} => {
context_compaction_count += 1;
if context_compaction_output.is_none() {
context_compaction_output = Some(item);
}
if let ResponseItem::Compaction { .. } = item {
compaction_count += 1;
if compaction_output.is_none() {
compaction_output = Some(item);
}
ResponseItem::ContextCompaction {
encrypted_content: None,
} => {
return Err(CodexErr::Fatal(
"remote compaction v2 returned context_compaction without encrypted_content"
.to_string(),
));
}
_ => {}
}
}
ResponseEvent::Completed { response_id, .. } => {
@@ -324,16 +327,16 @@ async fn collect_context_compaction_output(
));
};
if context_compaction_count != 1 {
if compaction_count != 1 {
return Err(CodexErr::Fatal(format!(
"remote compaction v2 expected exactly one context_compaction output item, got {context_compaction_count} from {output_item_count} output items"
"remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items"
)));
}
let Some(context_compaction_output) = context_compaction_output else {
unreachable!("context compaction output must exist when count is exactly one");
let Some(compaction_output) = compaction_output else {
unreachable!("compaction output must exist when count is exactly one");
};
Ok((context_compaction_output, response_id))
Ok((compaction_output, response_id))
}
fn build_v2_compacted_history(
@@ -410,8 +413,8 @@ mod tests {
encrypted_content: "old".to_string(),
},
];
let output = ResponseItem::ContextCompaction {
encrypted_content: Some("new".to_string()),
let output = ResponseItem::Compaction {
encrypted_content: "new".to_string(),
};
let history = build_v2_compacted_history(&input, output.clone());
@@ -428,9 +431,9 @@ mod tests {
}
#[tokio::test]
async fn collect_context_compaction_output_accepts_additional_output_items() {
let context_compaction = ResponseItem::ContextCompaction {
encrypted_content: Some("encrypted".to_string()),
async fn collect_compaction_output_accepts_additional_output_items() {
let compaction = ResponseItem::Compaction {
encrypted_content: "encrypted".to_string(),
};
let stream = response_stream(vec![
Ok(ResponseEvent::OutputItemDone(message(
@@ -438,7 +441,7 @@ mod tests {
"IGNORED_COMPACT_REPLY",
Some(MessagePhase::FinalAnswer),
))),
Ok(ResponseEvent::OutputItemDone(context_compaction.clone())),
Ok(ResponseEvent::OutputItemDone(compaction.clone())),
Ok(ResponseEvent::Completed {
response_id: "resp-compact".to_string(),
token_usage: None,
@@ -446,11 +449,11 @@ mod tests {
}),
]);
let (output, response_id) = collect_context_compaction_output(stream)
let (output, response_id) = collect_compaction_output(stream)
.await
.expect("context compaction should be collected");
.expect("compaction should be collected");
assert_eq!(output, context_compaction);
assert_eq!(output, compaction);
assert_eq!(response_id, "resp-compact");
}
}
@@ -29,7 +29,6 @@ use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
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 +820,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 +913,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()
);
@@ -1233,11 +1226,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(),
@@ -1570,9 +1561,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 +2342,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 +2394,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",
+221 -28
View File
@@ -740,6 +740,7 @@ default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
"/tmp/secret.env" = "deny"
[permissions.workspace.filesystem.":workspace_roots"]
"." = "write"
@@ -777,6 +778,10 @@ allow_upstream_proxy = false
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
"/tmp/secret.env".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Deny),
),
(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
@@ -1453,6 +1458,30 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io:
Ok(())
}
#[test]
fn permission_snapshot_setter_preserves_permission_constraints() {
let initial_profile = PermissionProfile::read_only();
let mut permissions = Permissions::from_approval_and_profile(
Constrained::allow_any(AskForApproval::Never),
Constrained::allow_only(initial_profile.clone()),
)
.expect("initial permissions should satisfy constraints");
let err = permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE),
))
.expect_err("workspace profile should violate read-only constraint");
assert_eq!(permissions.permission_profile(), &initial_profile);
assert_eq!(permissions.active_permission_profile(), None);
assert!(
matches!(err, ConstraintError::InvalidValue { .. }),
"expected invalid value constraint error, got {err:?}"
);
}
#[tokio::test]
async fn permission_profile_override_preserves_managed_unrestricted_filesystem()
-> std::io::Result<()> {
@@ -1676,7 +1705,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std:
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("**/*.env".to_string(), FileSystemAccessMode::None),
("**/*.env".to_string(), FileSystemAccessMode::Deny),
])),
)]),
}),
@@ -1715,7 +1744,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std:
path: FileSystemPath::GlobPattern {
pattern: expected_pattern,
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
})
);
}
@@ -1798,6 +1827,8 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl
)
.await?;
assert!(config.explicit_permission_profile_mode);
assert!(config.custom_permission_profile_ids.is_empty());
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
@@ -1905,9 +1936,28 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots(
)
.await?;
let cwd_abs = cwd.abs();
let runtime_root_abs = runtime_root.abs();
let profile_root_abs = profile_root.abs();
assert_eq!(
config.workspace_roots,
vec![cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.permissions.workspace_roots(),
&[cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.effective_workspace_roots(),
vec![
cwd_abs.clone(),
runtime_root_abs.clone(),
profile_root_abs.clone()
]
);
let policy = config.permissions.file_system_sandbox_policy();
for root in [cwd.abs(), runtime_root.abs(), profile_root_abs.clone()] {
for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] {
assert!(
policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()),
"expected workspace root to be writable, policy: {policy:?}"
@@ -2334,6 +2384,10 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
)
.await?;
assert_eq!(
config.custom_permission_profile_ids,
vec!["workspace".to_string()]
);
let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
codex_home.path().join("memories"),
)?)?;
@@ -3806,6 +3860,63 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config()
mcp_config.configured_mcp_servers.get("sample"),
Some(&http_mcp("https://sample.example/mcp"))
);
assert_eq!(
mcp_config.plugin_ids_by_mcp_server_name,
HashMap::from([("sample".to_string(), "sample@test".to_string())])
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://plugin.example/mcp"
}
}
}"#,
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[mcp_servers.sample]
url = "https://user.example/mcp"
[plugins."sample@test"]
enabled = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.configured_mcp_servers.get("sample"),
Some(&http_mcp("https://user.example/mcp"))
);
assert!(mcp_config.plugin_ids_by_mcp_server_name.is_empty());
Ok(())
}
@@ -4260,8 +4371,7 @@ fn web_search_mode_disabled_overrides_legacy_request() {
#[test]
fn web_search_mode_for_turn_uses_preference_for_read_only() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let permission_profile =
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy());
let permission_profile = PermissionProfile::read_only();
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile);
assert_eq!(mode, WebSearchMode::Cached);
@@ -4858,12 +4968,14 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
config.apps_mcp_product_sku = Some("tpp".to_string());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.apps_enabled);
assert_eq!(
mcp_config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp"));
let _ = config.features.disable(Feature::Apps);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
@@ -7604,6 +7716,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
@@ -7620,6 +7733,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
@@ -7673,6 +7788,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -8054,6 +8170,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai-custom".to_string(),
model_provider: fixture.openai_custom_provider.clone(),
@@ -8070,6 +8187,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
@@ -8123,6 +8242,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -8218,6 +8338,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
@@ -8234,6 +8355,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
@@ -8287,6 +8410,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -8367,6 +8491,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
@@ -8383,6 +8508,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
@@ -8436,6 +8563,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -8877,29 +9005,26 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
let active_project = ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
};
let constrained = Constrained::new(
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|candidate| {
if matches!(
candidate,
PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted { entries, .. },
..
} if entries
.iter()
.any(|entry| entry.access.can_write())
) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: format!("{candidate:?}"),
allowed: "[WorkspaceWrite]".to_string(),
requirement_source: RequirementSource::Unknown,
})
}
},
)?;
let constrained = Constrained::new(PermissionProfile::workspace_write(), |candidate| {
if matches!(
candidate,
PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted { entries, .. },
..
} if entries
.iter()
.any(|entry| entry.access.can_write())
) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: format!("{candidate:?}"),
allowed: "[WorkspaceWrite]".to_string(),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
@@ -9158,6 +9283,27 @@ path = "/custom/mcp"
Ok(())
}
#[tokio::test]
async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
apps_mcp_product_sku = "tpp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP SKU");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.apps_mcp_product_sku.as_deref(), Some("tpp"));
Ok(())
}
#[tokio::test]
async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -10046,6 +10192,7 @@ usage_hint_enabled = false
usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
subagent_usage_hint_text = "Subagent guidance."
tool_namespace = "agents"
hide_spawn_agent_metadata = true
non_code_mode_only = true
"#,
@@ -10076,6 +10223,10 @@ non_code_mode_only = true
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some("Subagent guidance.")
);
assert_eq!(
config.multi_agent_v2.tool_namespace.as_deref(),
Some("agents")
);
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
assert!(config.multi_agent_v2.non_code_mode_only);
@@ -10098,6 +10249,7 @@ usage_hint_enabled = true
usage_hint_text = "base hint"
root_agent_usage_hint_text = "base root hint"
subagent_usage_hint_text = "base subagent hint"
tool_namespace = "base_agents"
hide_spawn_agent_metadata = true
non_code_mode_only = false
@@ -10110,6 +10262,7 @@ usage_hint_enabled = false
usage_hint_text = "profile hint"
root_agent_usage_hint_text = "profile root hint"
subagent_usage_hint_text = "profile subagent hint"
tool_namespace = "profile_agents"
hide_spawn_agent_metadata = false
non_code_mode_only = true
"#,
@@ -10138,6 +10291,10 @@ non_code_mode_only = true
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some("profile subagent hint")
);
assert_eq!(
config.multi_agent_v2.tool_namespace.as_deref(),
Some("profile_agents")
);
assert!(!config.multi_agent_v2.hide_spawn_agent_metadata);
assert!(config.multi_agent_v2.non_code_mode_only);
@@ -10398,6 +10555,42 @@ default_wait_timeout_ms = 2500
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> {
for (namespace, expected_message) in [
(
"bad namespace",
"features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$",
),
(
"functions",
"features.multi_agent_v2.tool_namespace uses a reserved namespace: functions",
),
] {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[features.multi_agent_v2]
enabled = true
tool_namespace = "{namespace}"
"#
),
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("invalid multi_agent_v2 tool namespace should fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(err.to_string(), expected_message);
}
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+136 -46
View File
@@ -77,6 +77,7 @@ use codex_model_provider_info::built_in_model_providers;
use codex_model_provider_info::merge_configured_model_providers;
use codex_models_manager::ModelsManagerConfig;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
@@ -107,6 +108,7 @@ use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::hash_map::Entry;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
@@ -149,6 +151,7 @@ pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub(crate) use permissions::resolve_permission_profile;
pub use resolved_permission_profile::PermissionProfileSnapshot;
pub(crate) use resolved_permission_profile::PermissionProfileState;
const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200;
@@ -311,30 +314,13 @@ impl Permissions {
///
/// This is a trusted-state bridge for consumers of `SessionConfigured`.
/// Config loading and app-server selection should resolve named profiles
/// through config instead of constructing this pair directly.
/// through config instead of constructing a snapshot directly.
pub fn set_permission_profile_from_session_snapshot(
&mut self,
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
snapshot: PermissionProfileSnapshot,
) -> ConstraintResult<()> {
self.set_permission_profile_from_session_snapshot_with_profile_workspace_roots(
permission_profile,
active_permission_profile,
Vec::new(),
)
}
pub fn set_permission_profile_from_session_snapshot_with_profile_workspace_roots(
&mut self,
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
profile_workspace_roots: Vec<AbsolutePathBuf>,
) -> ConstraintResult<()> {
self.permission_profile_state.set_active_permission_profile(
permission_profile,
active_permission_profile,
profile_workspace_roots,
)
self.permission_profile_state
.set_permission_profile_snapshot(snapshot)
}
/// Replace the current permission constraints with a trusted session
@@ -342,26 +328,12 @@ impl Permissions {
/// after their local config constraints reject the snapshot.
pub fn replace_permission_profile_from_session_snapshot(
&mut self,
permission_profile: Constrained<PermissionProfile>,
active_permission_profile: Option<ActivePermissionProfile>,
snapshot: PermissionProfileSnapshot,
) -> ConstraintResult<()> {
self.replace_permission_profile_from_session_snapshot_with_profile_workspace_roots(
let permission_profile = Constrained::allow_only(snapshot.permission_profile().clone());
self.permission_profile_state = PermissionProfileState::from_constrained_resolved(
permission_profile,
active_permission_profile,
Vec::new(),
)
}
pub fn replace_permission_profile_from_session_snapshot_with_profile_workspace_roots(
&mut self,
permission_profile: Constrained<PermissionProfile>,
active_permission_profile: Option<ActivePermissionProfile>,
profile_workspace_roots: Vec<AbsolutePathBuf>,
) -> ConstraintResult<()> {
self.permission_profile_state = PermissionProfileState::from_constrained_active_profile(
permission_profile,
active_permission_profile,
profile_workspace_roots,
snapshot.into_resolved_permission_profile(),
)?;
Ok(())
}
@@ -556,6 +528,10 @@ pub struct Config {
/// Token usage threshold triggering auto-compaction of conversation history.
pub model_auto_compact_token_limit: Option<i64>,
/// Controls whether `model_auto_compact_token_limit` applies to the full
/// active context or only tokens after the carried compaction-window prefix.
pub model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope,
/// Key into the model_providers map that specifies which provider to use.
pub model_provider_id: String,
@@ -568,6 +544,13 @@ pub struct Config {
/// Effective permission configuration for shell tool execution.
pub permissions: Permissions,
/// Whether config explicitly selected named permissions profiles instead
/// of the legacy `sandbox_mode` syntax.
pub explicit_permission_profile_mode: bool,
/// User-defined permission profile IDs available from effective config.
pub custom_permission_profile_ids: Vec<String>,
/// Configures who approval requests are routed to for review once they have
/// been escalated. This does not disable separate safety checks such as
/// ARC.
@@ -871,6 +854,9 @@ pub struct Config {
/// Optional path override for the host-owned apps MCP server.
pub apps_mcp_path_override: Option<String>,
/// Optional product SKU forwarded to the host-owned apps MCP server.
pub apps_mcp_product_sku: Option<String>,
/// Machine-local realtime audio device preferences used by realtime voice.
pub realtime_audio: RealtimeAudioConfig,
@@ -980,6 +966,7 @@ pub struct MultiAgentV2Config {
pub usage_hint_text: Option<String>,
pub root_agent_usage_hint_text: Option<String>,
pub subagent_usage_hint_text: Option<String>,
pub tool_namespace: Option<String>,
pub hide_spawn_agent_metadata: bool,
pub non_code_mode_only: bool,
}
@@ -996,6 +983,7 @@ impl Default for MultiAgentV2Config {
usage_hint_text: None,
root_agent_usage_hint_text: None,
subagent_usage_hint_text: None,
tool_namespace: None,
hide_spawn_agent_metadata: false,
non_code_mode_only: false,
}
@@ -1231,6 +1219,13 @@ impl Config {
Ok(())
}
pub fn effective_workspace_roots(&self) -> Vec<AbsolutePathBuf> {
let mut workspace_roots = self.workspace_roots.clone();
workspace_roots.extend(self.permissions.profile_workspace_roots().iter().cloned());
dedupe_absolute_paths(&mut workspace_roots);
workspace_roots
}
pub fn to_models_manager_config(&self) -> ModelsManagerConfig {
ModelsManagerConfig {
model_context_window: self.model_context_window,
@@ -1261,6 +1256,7 @@ impl Config {
let plugins_input = self.plugins_config_input();
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
let mut configured_mcp_servers = self.mcp_servers.get().clone();
let mut plugin_ids_by_mcp_server_name = HashMap::new();
for plugin in loaded_plugins
.plugins()
.iter()
@@ -1273,7 +1269,10 @@ impl Config {
self.config_layer_stack.requirements().plugins.as_ref(),
);
for (name, plugin_server) in plugin_mcp_servers {
configured_mcp_servers.entry(name).or_insert(plugin_server);
if let Entry::Vacant(entry) = configured_mcp_servers.entry(name.clone()) {
entry.insert(plugin_server);
plugin_ids_by_mcp_server_name.insert(name, plugin.config_name.clone());
}
}
}
if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref()
@@ -1283,10 +1282,13 @@ impl Config {
// above.
filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements));
}
plugin_ids_by_mcp_server_name
.retain(|server_name, _| configured_mcp_servers.contains_key(server_name));
McpConfig {
chatgpt_base_url: self.chatgpt_base_url.clone(),
apps_mcp_path_override: self.apps_mcp_path_override.clone(),
apps_mcp_product_sku: self.apps_mcp_product_sku.clone(),
codex_home: self.codex_home.to_path_buf(),
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
mcp_oauth_callback_port: self.mcp_oauth_callback_port,
@@ -1309,6 +1311,7 @@ impl Config {
ElicitationCapability::default()
},
configured_mcp_servers,
plugin_ids_by_mcp_server_name,
plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(),
}
}
@@ -2028,7 +2031,7 @@ fn apply_managed_filesystem_constraints(
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
pattern: deny_read.as_str().to_string(),
},
access: codex_protocol::permissions::FileSystemAccessMode::None,
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
}
} else {
let Ok(path) = AbsolutePathBuf::try_from(deny_read.as_str()) else {
@@ -2036,7 +2039,7 @@ fn apply_managed_filesystem_constraints(
};
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path { path },
access: codex_protocol::permissions::FileSystemAccessMode::None,
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
}
};
if !file_system_sandbox_policy
@@ -2197,6 +2200,11 @@ fn resolve_multi_agent_v2_config(
.or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref()))
.cloned()
.or(default.subagent_usage_hint_text);
let tool_namespace = profile
.and_then(|config| config.tool_namespace.as_ref())
.or_else(|| base.and_then(|config| config.tool_namespace.as_ref()))
.cloned()
.or(default.tool_namespace);
let hide_spawn_agent_metadata = profile
.and_then(|config| config.hide_spawn_agent_metadata)
.or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata))
@@ -2215,6 +2223,7 @@ fn resolve_multi_agent_v2_config(
usage_hint_text,
root_agent_usage_hint_text,
subagent_usage_hint_text,
tool_namespace,
hide_spawn_agent_metadata,
non_code_mode_only,
}
@@ -2309,6 +2318,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res
Ok(())
}
fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> {
const LABEL: &str = "features.multi_agent_v2.tool_namespace";
const MAX_LEN: usize = 64;
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
"api_tool",
"browser",
"computer",
"container",
"file_search",
"functions",
"image_gen",
"multi_tool_use",
"python",
"python_user_visible",
"submodel_delegator",
"terminal",
"tool_search",
"web",
];
let Some(namespace) = namespace else {
return Ok(());
};
if namespace.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{LABEL} must not be empty"),
));
}
if namespace.trim() != namespace {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{LABEL} must not have leading or trailing whitespace"),
));
}
if !namespace
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"),
));
}
if namespace.chars().count() > MAX_LEN {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{LABEL} must be at most {MAX_LEN} characters"),
));
}
if namespace == "mcp"
|| namespace.starts_with("mcp__")
|| RESERVED_RESPONSES_NAMESPACES.contains(&namespace)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{LABEL} uses a reserved namespace: {namespace}"),
));
}
Ok(())
}
impl Config {
#[cfg(test)]
async fn load_from_base_config_with_overrides(
@@ -2365,8 +2437,10 @@ impl Config {
guardian_policy_config_source: _,
} = config_layer_stack.requirements().clone();
let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home))
.map(|loaded| loaded.contents);
let user_instructions =
AgentsMdManager::load_global_instructions(LOCAL_FS.as_ref(), Some(&codex_home))
.await
.map(|loaded| loaded.contents);
let mut startup_warnings = config_layer_stack
.startup_warnings()
.unwrap_or_default()
@@ -2542,6 +2616,17 @@ impl Config {
Some(PermissionConfigSyntax::Profiles)
)
|| permission_config_syntax.is_none();
let explicit_permission_profile_mode = default_permissions_override.is_some()
|| matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
);
let custom_permission_profile_ids = cfg
.permissions
.as_ref()
.map_or_else(Vec::new, |permissions| {
permissions.entries.keys().cloned().collect()
});
let using_implicit_builtin_profile =
permission_config_syntax.is_none() && default_permissions.is_none();
let should_seed_legacy_workspace_roots = default_permissions.is_none()
@@ -2671,8 +2756,6 @@ impl Config {
configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone());
}
dedupe_absolute_paths(&mut configured_workspace_roots);
workspace_roots.extend(configured_workspace_roots.iter().cloned());
dedupe_absolute_paths(&mut workspace_roots);
file_system_sandbox_policy = file_system_sandbox_policy
.with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots);
let mut permission_profile = if let Some(permission_profile) =
@@ -2932,6 +3015,7 @@ impl Config {
"features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms",
));
}
validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?;
let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads);
let agent_max_threads = if features.enabled(Feature::MultiAgentV2) {
if agent_max_threads_from_config.is_some() {
@@ -3294,6 +3378,9 @@ impl Config {
review_model,
model_context_window: cfg.model_context_window,
model_auto_compact_token_limit: cfg.model_auto_compact_token_limit,
model_auto_compact_token_limit_scope: cfg
.model_auto_compact_token_limit_scope
.unwrap_or_default(),
model_provider_id,
model_provider,
cwd: resolved_cwd,
@@ -3310,6 +3397,8 @@ impl Config {
windows_sandbox_mode,
windows_sandbox_private_desktop,
},
explicit_permission_profile_mode,
custom_permission_profile_ids,
approvals_reviewer: constrained_approvals_reviewer.value(),
enforce_residency: enforce_residency.value,
notify: cfg.notify,
@@ -3414,6 +3503,7 @@ impl Config {
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
apps_mcp_path_override,
apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(),
realtime_audio: cfg
.audio
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {
@@ -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");
+10 -10
View File
@@ -243,7 +243,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."
),
);
}
@@ -414,7 +414,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.
@@ -449,7 +449,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
@@ -520,10 +520,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)?;
@@ -559,7 +559,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"
),
))
}
@@ -569,7 +569,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());
@@ -577,7 +577,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}"));
@@ -597,7 +597,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());
}
@@ -605,7 +605,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}"));
}
}
@@ -326,7 +326,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 +340,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 +351,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),
])),
)]),
};
@@ -435,7 +435,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,
@@ -101,24 +114,6 @@ impl ResolvedPermissionProfile {
}
}
fn with_permission_profile(&self, permission_profile: PermissionProfile) -> Self {
match self {
Self::Legacy(_) => Self::legacy(permission_profile),
Self::BuiltIn(profile) => Self::BuiltIn(BuiltInPermissionProfile {
id: profile.id,
extends: profile.extends.clone(),
permission_profile,
profile_workspace_roots: profile.profile_workspace_roots.clone(),
}),
Self::Named(profile) => Self::Named(NamedPermissionProfile {
id: profile.id.clone(),
extends: profile.extends.clone(),
permission_profile,
profile_workspace_roots: profile.profile_workspace_roots.clone(),
}),
}
}
pub(crate) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
match self {
Self::Legacy(_) => None,
@@ -142,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>,
@@ -189,19 +271,6 @@ impl PermissionProfileState {
self.resolved_permission_profile.get().permission_profile()
}
pub(crate) fn clone_with_permission_profile(
&self,
permission_profile: PermissionProfile,
) -> ConstraintResult<Self> {
let candidate = self
.resolved_permission_profile
.get()
.with_permission_profile(permission_profile);
let mut state = self.clone();
state.resolved_permission_profile.set(candidate)?;
Ok(state)
}
pub(crate) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
self.resolved_permission_profile
.get()
@@ -230,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 -6
View File
@@ -198,7 +198,8 @@ 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,
@@ -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,7 +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.
McpRuntimeEnvironment::new(
environment_manager.default_or_local_environment(),
environment_manager.try_local_environment(),
config.cwd.to_path_buf(),
),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
@@ -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() {
@@ -38,6 +41,18 @@ fn detects_goal_context_fragment() {
}));
}
#[test]
fn contextual_user_fragment_is_dyn_compatible() {
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
});
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
}
+11 -3
View File
@@ -8,9 +8,17 @@ pub(crate) struct GoalContext {
}
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(
@@ -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!(
@@ -400,6 +400,7 @@ impl ContextManager {
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => item.clone(),
}
@@ -490,6 +491,7 @@ fn is_api_message(message: &ResponseItem) -> bool {
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger => false,
ResponseItem::Other => false,
}
}
@@ -688,6 +690,7 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger => false,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
@@ -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,
}
}
+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 } => {
+16 -4
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(),
+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");
+176 -62
View File
@@ -7,6 +7,7 @@
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 +16,23 @@ 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 +156,9 @@ pub(crate) enum GoalRuntimeEvent<'a> {
MaybeContinueIfIdle,
TaskAborted {
turn_context: Option<&'a TurnContext>,
reason: TurnAbortReason,
},
UsageLimitReached {
turn_context: &'a TurnContext,
},
ExternalMutationStarting,
ExternalSet {
@@ -331,8 +336,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 +389,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 +428,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))
@@ -459,10 +466,14 @@ impl Session {
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 {
@@ -482,6 +493,7 @@ impl Session {
} else {
replacing_goal = true;
state_db
.thread_goals()
.replace_thread_goal(
self.conversation_id,
objective,
@@ -493,11 +505,15 @@ 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 {
@@ -516,6 +532,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 +550,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;
@@ -581,6 +606,7 @@ impl Session {
)
.await?;
let goal = state_db
.thread_goals()
.insert_thread_goal(
self.conversation_id,
objective,
@@ -595,6 +621,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 +673,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 +702,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 +759,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 +794,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 +823,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)
@@ -801,7 +867,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,
@@ -886,11 +956,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 +979,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,6 +1023,7 @@ 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,
@@ -985,6 +1046,8 @@ impl Session {
matches!(budget_limit_steering, BudgetLimitSteering::Suppressed)
}
codex_state::ThreadGoalStatus::Paused
| codex_state::ThreadGoalStatus::Blocked
| codex_state::ThreadGoalStatus::UsageLimited
| codex_state::ThreadGoalStatus::Complete => true,
};
{
@@ -1085,6 +1148,7 @@ impl Session {
.await?;
match state_db
.thread_goals()
.account_thread_goal_usage(
self.conversation_id,
time_delta_seconds,
@@ -1122,8 +1186,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 +1207,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 +1259,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 +1275,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 +1311,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 +1347,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())
@@ -1312,11 +1394,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 +1419,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 +1439,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 +1531,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
}
@@ -1526,7 +1634,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(),
role: GoalContext::role().to_string(),
content: vec![ContentItem::InputText {
text: context.render(),
}],
@@ -1553,6 +1661,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 +1674,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 +1754,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 +1771,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\""));
}
+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")
)
+110 -77
View File
@@ -13,7 +13,7 @@ use codex_hooks::PostToolUseRequest;
use codex_hooks::PreToolUseOutcome;
use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::SessionStartTarget;
use codex_hooks::StartHookTarget;
use codex_hooks::StopHookTarget;
use codex_hooks::StopOutcome;
use codex_hooks::SubagentHookContext;
@@ -22,7 +22,7 @@ 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;
@@ -34,13 +34,13 @@ use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
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;
@@ -56,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,
@@ -121,6 +105,9 @@ pub(crate) async fn run_pending_session_start_hooks(
return false;
};
// 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!(
@@ -129,14 +116,14 @@ pub(crate) async fn run_pending_session_start_hooks(
) =>
{
let context = subagent_hook_context(sess, agent_role);
SessionStartTarget::SubagentStart {
StartHookTarget::SubagentStart {
turn_id: turn_context.sub_id.clone(),
agent_id: context.agent_id,
agent_type: context.agent_type,
}
}
SessionSource::SubAgent(_) => return false,
_ => SessionStartTarget::SessionStart {
_ => StartHookTarget::SessionStart {
source: session_start_source,
},
};
@@ -307,6 +294,8 @@ pub(crate) async fn run_turn_stop_hooks(
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,
@@ -344,6 +333,8 @@ pub(crate) async fn run_turn_stop_hooks(
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),
};
@@ -433,84 +424,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(),
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,
};
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>(
+1 -3
View File
@@ -20,7 +20,7 @@ mod compact_remote;
mod compact_remote_v2;
mod config_lock;
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;
@@ -90,13 +90,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;
+14 -7
View File
@@ -974,6 +974,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 +984,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 +1070,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))
}
@@ -1480,13 +1488,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 +1525,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()),
+47 -6
View File
@@ -76,6 +76,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,
@@ -1099,6 +1100,29 @@ 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 codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() {
let (_, turn_context) = make_session_and_context().await;
@@ -1111,6 +1135,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,
@@ -1210,8 +1235,8 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
let environment = session
.services
.environment_manager
.default_environment()
.unwrap_or_else(|| session.services.environment_manager.local_environment());
.default_or_local_environment()
.expect("test session should have an MCP runtime environment");
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
&HashMap::new(),
turn_context.config.mcp_oauth_credentials_store_mode,
@@ -1220,10 +1245,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, {
#[allow(deprecated)]
turn_context.cwd.to_path_buf()
}),
codex_mcp::McpRuntimeEnvironment::new(
Some(environment),
session.services.environment_manager.try_local_environment(),
{
#[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,
@@ -1573,6 +1602,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,
@@ -2290,6 +2320,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,
@@ -2363,6 +2394,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,
@@ -2419,6 +2451,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,
@@ -2554,6 +2587,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,
@@ -2640,6 +2674,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,
@@ -2693,6 +2728,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,
@@ -2772,6 +2808,7 @@ async fn approve_mode_skips_arc_interrupt_for_model() {
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,
@@ -2839,6 +2876,7 @@ async fn custom_approve_mode_skips_arc_interrupt_for_model() {
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
@@ -2906,6 +2944,7 @@ async fn approve_mode_skips_arc_interrupt_without_annotations() {
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,
@@ -2978,6 +3017,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,
@@ -3046,6 +3086,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,
+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),
}
}
+6 -104
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;
@@ -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,64 +117,12 @@ 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",
@@ -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__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)]
@@ -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,
+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)
+136 -187
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,134 +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),
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,
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,
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
@@ -313,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;
@@ -632,12 +619,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;
}
}
@@ -656,7 +645,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.
@@ -775,54 +764,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 } => {
@@ -911,7 +858,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");
}
@@ -953,7 +900,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;
}
}
+378
View File
@@ -0,0 +1,378 @@
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
.tasks
.contains_key(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);
}
}
+4 -3
View File
@@ -291,14 +291,15 @@ impl Session {
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),
Some(Arc::clone(&turn_environment.environment)),
self.services.environment_manager.try_local_environment(),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
self.services
.environment_manager
.default_environment()
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
.default_or_local_environment(),
self.services.environment_manager.try_local_environment(),
#[allow(deprecated)]
turn_context.cwd.to_path_buf(),
),
+94 -241
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;
@@ -80,6 +78,7 @@ 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::Settings;
use codex_protocol::config_types::WebSearchMode;
@@ -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;
@@ -358,8 +359,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;
@@ -621,6 +622,7 @@ impl Codex {
permission_profile_state: session_permission_profile_state_from_config(&config)?,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
workspace_roots: config.workspace_roots.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: environment_selections.to_selections(),
@@ -821,15 +823,7 @@ fn get_service_tier(
fn session_permission_profile_state_from_config(
config: &Config,
) -> CodexResult<PermissionProfileState> {
config
.permissions
.permission_profile_state()
.clone_with_permission_profile(config.permissions.effective_permission_profile())
.map_err(|err| {
CodexErr::Fatal(format!(
"failed to materialize workspace roots for session permissions: {err}"
))
})
Ok(config.permissions.permission_profile_state().clone())
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
@@ -894,22 +888,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 {
@@ -1102,6 +1080,7 @@ impl Session {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
thread_settings: Default::default(),
},
/*mirror_user_text_to_realtime*/ None,
)
@@ -1113,6 +1092,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()
@@ -1271,11 +1255,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(),
@@ -1391,12 +1405,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(
@@ -2589,8 +2606,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;
@@ -2697,14 +2717,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) =
@@ -2956,16 +2968,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;
}
}
}
@@ -3001,6 +3021,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;
}
@@ -3033,16 +3058,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);
@@ -3085,11 +3100,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));
@@ -3129,10 +3144,6 @@ 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));
@@ -3166,6 +3177,10 @@ impl Session {
None => return Err(SteerInputError::NoActiveTurn(input)),
}
if input.is_empty() {
return Err(SteerInputError::EmptyInput);
}
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata
&& let Some((_, active_task)) = active_turn.tasks.first()
{
@@ -3175,199 +3190,39 @@ impl Session {
.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;
@@ -3445,8 +3300,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,
+16 -54
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(
crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()),
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
@@ -143,6 +133,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),
@@ -209,6 +200,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()),
@@ -237,6 +229,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),
@@ -307,6 +300,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()),
@@ -335,6 +329,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),
@@ -397,6 +392,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()),
@@ -425,6 +421,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),
@@ -515,6 +512,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()),
@@ -603,6 +601,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),
@@ -655,6 +654,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),
@@ -727,6 +727,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()),
@@ -753,6 +754,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 {
@@ -884,6 +886,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 +915,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 +929,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
@@ -952,6 +950,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 +990,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 +1004,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 +1017,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 +1031,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
@@ -1065,6 +1054,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),
@@ -1091,6 +1081,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 +1130,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,11 +1144,7 @@ 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![
@@ -1176,6 +1162,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),
@@ -1202,6 +1189,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 +1242,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 +1256,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
@@ -1296,6 +1279,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),
@@ -1322,6 +1306,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 {
@@ -1372,6 +1357,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 +1393,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 +1407,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
@@ -1450,6 +1431,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),
@@ -1476,6 +1458,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 {
+63 -23
View File
@@ -1,5 +1,7 @@
use super::input_queue::InputQueue;
use super::*;
use crate::goals::GoalRuntimeState;
use crate::state::ActiveTurn;
use codex_protocol::SessionId;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::permissions::FileSystemPath;
@@ -27,9 +29,7 @@ pub(crate) struct Session {
pub(super) pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(super) mailbox: Mailbox,
pub(super) mailbox_rx: Mutex<MailboxReceiver>,
pub(super) idle_pending_input: Mutex<Vec<ResponseInputItem>>, // TODO (jif) merge with mailbox!
pub(crate) input_queue: InputQueue,
pub(crate) goal_runtime: GoalRuntimeState,
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
@@ -63,9 +63,9 @@ pub(crate) struct SessionConfiguration {
/// When to escalate for approval for execution
pub(super) approval_policy: Constrained<AskForApproval>,
pub(super) approvals_reviewer: ApprovalsReviewer,
/// Permission profile state for the session. Keep the constrained profile
/// and selected profile id in sync by using the methods below instead of
/// mutating the fields independently.
/// Permission profile state for the session. Keep the constrained profile,
/// active profile id, and profile-defined workspace roots in sync by using
/// the methods below instead of mutating the fields independently.
pub(super) permission_profile_state: PermissionProfileState,
pub(super) windows_sandbox_level: WindowsSandboxLevel,
@@ -74,6 +74,9 @@ pub(crate) struct SessionConfiguration {
/// execution sandbox are resolved against this directory **instead** of
/// the process-wide current working directory.
pub(super) cwd: AbsolutePathBuf,
/// Thread-scoped runtime workspace roots for materializing symbolic
/// workspace permissions at session runtime.
pub(super) workspace_roots: Vec<AbsolutePathBuf>,
/// Directory containing all Codex state for this session.
pub(super) codex_home: AbsolutePathBuf,
/// Optional user-facing name for the thread, updated during the session.
@@ -107,13 +110,20 @@ impl SessionConfiguration {
}
pub(super) fn permission_profile(&self) -> PermissionProfile {
self.permission_profile_state.permission_profile().clone()
self.permission_profile_state
.permission_profile()
.clone()
.materialize_project_roots_with_workspace_roots(&self.workspace_roots)
}
pub(super) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
self.permission_profile_state.active_permission_profile()
}
pub(super) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] {
self.permission_profile_state.profile_workspace_roots()
}
pub(super) fn apply_permission_profile_to_permissions(
&self,
permissions: &mut crate::config::Permissions,
@@ -164,9 +174,13 @@ impl SessionConfiguration {
permission_profile: self.permission_profile(),
active_permission_profile: self.active_permission_profile(),
cwd: self.cwd.clone(),
workspace_roots: self.workspace_roots.clone(),
profile_workspace_roots: self.profile_workspace_roots().to_vec(),
ephemeral: self.original_config_do_not_use.ephemeral,
reasoning_effort: self.collaboration_mode.reasoning_effort(),
reasoning_summary: self.model_reasoning_summary,
personality: self.personality,
collaboration_mode: self.collaboration_mode.clone(),
session_source: self.session_source.clone(),
thread_source: self.thread_source,
}
@@ -243,6 +257,23 @@ impl SessionConfiguration {
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
next_configuration.cwd = absolute_cwd;
if let Some(workspace_roots) = updates.workspace_roots.clone() {
next_configuration.workspace_roots = workspace_roots;
} else if cwd_changed && self.workspace_roots.contains(&self.cwd) {
let mut retargeted_workspace_roots =
Vec::with_capacity(next_configuration.workspace_roots.len());
for root in &self.workspace_roots {
let root = if root == &self.cwd {
next_configuration.cwd.clone()
} else {
root.clone()
};
if !retargeted_workspace_roots.contains(&root) {
retargeted_workspace_roots.push(root);
}
}
next_configuration.workspace_roots = retargeted_workspace_roots;
}
if let Some(permission_profile) = updates.permission_profile.clone() {
let active_permission_profile =
@@ -256,6 +287,7 @@ impl SessionConfiguration {
next_configuration.set_permission_profile_projection(
permission_profile,
active_permission_profile,
updates.profile_workspace_roots.clone().unwrap_or_default(),
Some(&current_file_system_sandbox_policy),
)?;
} else if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
@@ -311,6 +343,7 @@ impl SessionConfiguration {
&mut self,
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
profile_workspace_roots: Vec<AbsolutePathBuf>,
preserve_deny_reads_from: Option<&FileSystemSandboxPolicy>,
) -> ConstraintResult<()> {
let enforcement = permission_profile.enforcement();
@@ -326,17 +359,28 @@ impl SessionConfiguration {
&file_system_sandbox_policy,
network_sandbox_policy,
);
self.permission_profile_state.set_active_permission_profile(
effective_permission_profile,
active_permission_profile,
Vec::new(),
)
let permission_snapshot = match active_permission_profile {
Some(active_permission_profile) => {
PermissionProfileSnapshot::active_with_profile_workspace_roots(
effective_permission_profile,
active_permission_profile,
profile_workspace_roots,
)
}
None => PermissionProfileSnapshot::legacy(effective_permission_profile),
};
self.permission_profile_state
.set_permission_profile_snapshot(permission_snapshot)
}
}
#[derive(Default, Clone)]
pub(crate) struct SessionSettingsUpdate {
pub(crate) cwd: Option<PathBuf>,
pub(crate) workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub(crate) profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub(crate) approval_policy: Option<AskForApproval>,
pub(crate) approvals_reviewer: Option<ApprovalsReviewer>,
pub(crate) sandbox_policy: Option<SandboxPolicy>,
@@ -835,7 +879,7 @@ impl Session {
config: config.as_ref(),
session_store: &session_extension_data,
thread_store: &thread_extension_data,
});
}).await;
}
let services = SessionServices {
@@ -908,7 +952,6 @@ impl Session {
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
watch::channel(false);
let (mailbox, mailbox_rx) = Mailbox::new();
let sess = Arc::new(Session {
conversation_id: thread_id,
installation_id,
@@ -921,9 +964,7 @@ impl Session {
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
mailbox,
mailbox_rx: Mutex::new(mailbox_rx),
idle_pending_input: Mutex::new(Vec::new()),
input_queue: InputQueue::new(),
goal_runtime: GoalRuntimeState::new(),
guardian_review_session: GuardianReviewSessionManager::default(),
services,
@@ -1009,14 +1050,13 @@ impl Session {
.cloned();
let mcp_runtime_environment = match turn_environment {
Some(turn_environment) => McpRuntimeEnvironment::new(
Arc::clone(&turn_environment.environment),
Some(Arc::clone(&turn_environment.environment)),
sess.services.environment_manager.try_local_environment(),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
sess.services
.environment_manager
.default_environment()
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
sess.services.environment_manager.default_or_local_environment(),
sess.services.environment_manager.try_local_environment(),
session_configuration.cwd.to_path_buf(),
),
};
@@ -1088,7 +1128,7 @@ impl Session {
};
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
sess.record_initial_history(initial_history).await;
Box::pin(sess.record_initial_history(initial_history)).await;
{
let mut state = sess.state.lock().await;
state.set_pending_session_start_source(Some(session_start_source));
@@ -1,6 +1,5 @@
---
source: core/src/session/tests.rs
assertion_line: 1619
expression: snapshot
---
Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,9 @@ use crate::exec_policy::ExecPolicyManager;
use crate::guardian::GUARDIAN_REVIEWER_NAME;
use crate::sandboxing::SandboxPermissions;
use crate::test_support::models_manager_with_provider;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
@@ -22,8 +23,8 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile;
use codex_protocol::models::ContentItem;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::function_call_output_content_items_to_text;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
@@ -48,8 +49,23 @@ use tempfile::tempdir;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
fn expect_text_output(output: &FunctionToolOutput) -> String {
function_call_output_content_items_to_text(&output.body).unwrap_or_default()
fn expect_text_output<T>(output: &T) -> String
where
T: ToolOutput + ?Sized,
{
let response = output.to_response_item(
"call-guardian",
&ToolPayload::Function {
arguments: "{}".to_string(),
},
);
match response {
ResponseInputItem::FunctionCallOutput { output, .. }
| ResponseInputItem::CustomToolCallOutput { output, .. } => {
output.body.to_text().unwrap_or_default()
}
other => panic!("expected function output, got {other:?}"),
}
}
#[tokio::test]
File diff suppressed because it is too large Load Diff
+36 -127
View File
@@ -14,10 +14,6 @@ use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool {
auth_manager.is_some_and(AuthManager::current_auth_uses_codex_backend)
}
#[derive(Clone, Debug)]
pub(crate) struct TurnSkillsContext {
pub(crate) outcome: Arc<SkillLoadOutcome>,
@@ -84,7 +80,9 @@ pub struct TurnContext {
pub(crate) network: Option<NetworkProxy>,
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
pub(crate) tools_config: ToolsConfig,
pub(crate) available_models: Vec<ModelPreset>,
pub(crate) unified_exec_shell_mode: UnifiedExecShellMode,
pub(crate) goal_tools_supported: bool,
pub features: ManagedFeatures,
pub(crate) ghost_snapshot: GhostSnapshotConfig,
pub(crate) final_output_json_schema: Option<Value>,
@@ -156,6 +154,14 @@ impl TurnContext {
self.features.apps_enabled_for_auth(uses_codex_backend)
}
pub(crate) fn tool_environment_mode(&self) -> ToolEnvironmentMode {
ToolEnvironmentMode::from_count(self.environments.turn_environments.len())
}
pub(crate) fn goal_tools_enabled(&self) -> bool {
self.goal_tools_supported && self.features.get().enabled(Feature::Goals)
}
pub(crate) async fn with_model(
&self,
model: String,
@@ -195,60 +201,9 @@ impl TurnContext {
/*developer_instructions*/ None,
);
let features = self.features.clone();
let provider_capabilities = self.provider.capabilities();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &models_manager
.list_models(RefreshStrategy::OnlineIfUncached)
.await,
features: &features,
image_generation_tool_auth_allowed: image_generation_tool_auth_allowed(
self.auth_manager.as_deref(),
),
web_search_mode: self.tools_config.web_search_mode,
session_source: self.session_source.clone(),
permission_profile: &self.permission_profile,
windows_sandbox_level: self.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(self.tools_config.unified_exec_shell_mode.clone())
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_environment_mode(self.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(self.tools_config.goal_tools)
.with_max_concurrent_threads_per_session(
config
.features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.max_concurrent_threads_per_session),
)
.with_wait_agent_min_timeout_ms(
config
.features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.min_wait_timeout_ms),
)
.with_wait_agent_max_timeout_ms(
config
.features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.max_wait_timeout_ms),
)
.with_wait_agent_default_timeout_ms(
config
.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 available_models = models_manager
.list_models(RefreshStrategy::OnlineIfUncached)
.await;
Self {
sub_id: self.sub_id.clone(),
@@ -282,7 +237,9 @@ impl TurnContext {
network: self.network.clone(),
windows_sandbox_level: self.windows_sandbox_level,
shell_environment_policy: self.shell_environment_policy.clone(),
tools_config,
available_models,
unified_exec_shell_mode: self.unified_exec_shell_mode.clone(),
goal_tools_supported: self.goal_tools_supported,
features,
ghost_snapshot: self.ghost_snapshot.clone(),
final_output_json_schema: self.final_output_json_schema.clone(),
@@ -367,7 +324,6 @@ impl TurnContext {
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
trace_id: self.trace_id.clone(),
#[allow(deprecated)]
cwd: self.cwd.to_path_buf(),
current_date: self.current_date.clone(),
@@ -382,11 +338,7 @@ impl TurnContext {
collaboration_mode: Some(self.collaboration_mode.clone()),
realtime_active: Some(self.realtime_active),
effort: self.reasoning_effort,
summary: self.reasoning_summary,
user_instructions: self.user_instructions.clone(),
developer_instructions: self.developer_instructions.clone(),
final_output_json_schema: self.final_output_json_schema.clone(),
truncation_policy: Some(self.truncation_policy),
summary: ReasoningSummaryConfig::Auto,
}
}
@@ -432,6 +384,10 @@ impl Session {
let config = session_configuration.original_config_do_not_use.clone();
let mut per_turn_config = (*config).clone();
per_turn_config.cwd = cwd;
per_turn_config.workspace_roots = session_configuration.workspace_roots.clone();
per_turn_config
.permissions
.set_workspace_roots(session_configuration.workspace_roots.clone());
per_turn_config.model_reasoning_effort =
session_configuration.collaboration_mode.reasoning_effort();
per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary;
@@ -466,6 +422,10 @@ impl Session {
Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone());
config.model = Some(session_configuration.collaboration_mode.model().to_string());
config.permissions.approval_policy = session_configuration.approval_policy.clone();
config.workspace_roots = session_configuration.workspace_roots.clone();
config
.permissions
.set_workspace_roots(session_configuration.workspace_roots.clone());
config
}
@@ -499,71 +459,18 @@ impl Session {
model_info.slug.as_str(),
);
let session_source = session_configuration.session_source.clone();
let image_generation_tool_auth_allowed =
image_generation_tool_auth_allowed(auth_manager.as_deref());
let auth_manager_for_context = auth_manager.clone();
let provider_for_context = create_model_provider(provider, auth_manager);
let provider_capabilities = provider_for_context.capabilities();
let session_telemetry_for_context = session_telemetry;
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &models_manager.try_list_models().unwrap_or_default(),
features: &per_turn_config.features,
image_generation_tool_auth_allowed,
web_search_mode: Some(per_turn_config.web_search_mode.value()),
session_source: session_source.clone(),
permission_profile: &session_configuration.permission_profile(),
windows_sandbox_level: session_configuration.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(
crate::tools::spec::tool_user_shell_type(user_shell),
let available_models = models_manager.try_list_models().unwrap_or_default();
let shell_command_backend =
shell_command_backend_for_features(per_turn_config.features.get());
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
shell_command_backend,
crate::tools::tool_user_shell_type(user_shell),
shell_zsh_path,
main_execve_wrapper_exe,
)
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_environment_mode(ToolEnvironmentMode::from_count(
environments.turn_environments.len(),
))
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
.with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only)
.with_goal_tools_allowed(goal_tools_supported)
.with_max_concurrent_threads_per_session(
per_turn_config
.features
.enabled(Feature::MultiAgentV2)
.then_some(
per_turn_config
.multi_agent_v2
.max_concurrent_threads_per_session,
),
)
.with_wait_agent_min_timeout_ms(
per_turn_config
.features
.enabled(Feature::MultiAgentV2)
.then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms),
)
.with_wait_agent_max_timeout_ms(
per_turn_config
.features
.enabled(Feature::MultiAgentV2)
.then_some(per_turn_config.multi_agent_v2.max_wait_timeout_ms),
)
.with_wait_agent_default_timeout_ms(
per_turn_config
.features
.enabled(Feature::MultiAgentV2)
.then_some(per_turn_config.multi_agent_v2.default_wait_timeout_ms),
)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&per_turn_config.agent_roles,
));
);
let mut per_turn_config = per_turn_config;
per_turn_config.service_tier = per_turn_config
@@ -611,7 +518,9 @@ impl Session {
network,
windows_sandbox_level: session_configuration.windows_sandbox_level,
shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(),
tools_config,
available_models,
unified_exec_shell_mode,
goal_tools_supported,
features: per_turn_config.features.clone(),
ghost_snapshot: per_turn_config.ghost_snapshot.clone(),
final_output_json_schema: None,
@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
@@ -235,9 +234,6 @@ async fn schedule_startup_prewarm_inner(
let startup_router = built_tools(
session.as_ref(),
startup_turn_context.as_ref(),
&[],
&HashSet::new(),
/*skills_outcome*/ None,
&startup_cancellation_token,
)
.await?;
-126
View File
@@ -1,8 +1,3 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
use std::sync::Arc;
use crate::config::Config;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
@@ -10,14 +5,9 @@ use codex_analytics::InvocationType;
use codex_analytics::SkillInvocation;
use codex_analytics::build_track_events_context;
use codex_protocol::protocol::SkillScope;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PluginSkillRoot;
use tracing::warn;
pub use codex_core_skills::SkillDependencyInfo;
pub use codex_core_skills::SkillError;
pub use codex_core_skills::SkillLoadOutcome;
pub use codex_core_skills::SkillMetadata;
@@ -27,7 +17,6 @@ pub use codex_core_skills::SkillsLoadInput;
pub use codex_core_skills::SkillsManager;
pub use codex_core_skills::build_available_skills;
pub use codex_core_skills::build_skill_name_counts;
pub use codex_core_skills::collect_env_var_dependencies;
pub use codex_core_skills::config_rules;
pub use codex_core_skills::default_skill_metadata_budget;
pub use codex_core_skills::detect_implicit_skill_invocation_for_command;
@@ -56,121 +45,6 @@ pub(crate) fn skills_load_input_from_config(
)
}
pub(crate) async fn resolve_skill_dependencies_for_turn(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
dependencies: &[SkillDependencyInfo],
) {
if dependencies.is_empty() {
return;
}
let existing_env = sess.dependency_env().await;
let mut loaded_values = HashMap::new();
let mut missing = Vec::new();
let mut seen_names = HashSet::new();
for dependency in dependencies {
let name = dependency.name.clone();
if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) {
continue;
}
match env::var(&name) {
Ok(value) => {
loaded_values.insert(name.clone(), value);
}
Err(env::VarError::NotPresent) => {
missing.push(dependency.clone());
}
Err(err) => {
warn!("failed to read env var {name}: {err}");
missing.push(dependency.clone());
}
}
}
if !loaded_values.is_empty() {
sess.set_dependency_env(loaded_values).await;
}
if !missing.is_empty() {
request_skill_dependencies(sess, turn_context, &missing).await;
}
}
async fn request_skill_dependencies(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
dependencies: &[SkillDependencyInfo],
) {
let questions = dependencies
.iter()
.map(|dependency| {
let requirement = dependency.description.as_ref().map_or_else(
|| {
format!(
"The skill \"{}\" requires \"{}\" to be set.",
dependency.skill_name, dependency.name
)
},
|description| {
format!(
"The skill \"{}\" requires \"{}\" to be set ({}).",
dependency.skill_name, dependency.name, description
)
},
);
RequestUserInputQuestion {
id: dependency.name.clone(),
header: "Skill requires environment variable".to_string(),
question: format!(
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only."
),
is_other: false,
is_secret: true,
options: None,
}
})
.collect::<Vec<_>>();
if questions.is_empty() {
return;
}
let response = sess
.request_user_input(
turn_context,
format!("skill-deps-{}", turn_context.sub_id),
RequestUserInputArgs { questions },
)
.await
.unwrap_or_else(|| RequestUserInputResponse {
answers: HashMap::new(),
});
if response.answers.is_empty() {
return;
}
let mut values = HashMap::new();
for (name, answer) in response.answers {
let mut user_note = None;
for entry in &answer.answers {
if let Some(note) = entry.strip_prefix("user_note: ")
&& !note.trim().is_empty()
{
user_note = Some(note.trim().to_string());
}
}
if let Some(value) = user_note {
values.insert(name, value);
}
}
if values.is_empty() {
return;
}
sess.set_dependency_env(values).await;
}
pub(crate) async fn maybe_emit_implicit_skill_invocation(
sess: &Session,
turn_context: &TurnContext,
@@ -0,0 +1,145 @@
use codex_protocol::protocol::TokenUsage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AutoCompactWindowSnapshot {
pub(crate) ordinal: u64,
pub(crate) prefill_input_tokens: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AutoCompactWindowPrefill {
ServerObserved(i64),
Estimated(i64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct AutoCompactWindow {
ordinal: u64,
/// Absolute input-token baseline for the current compaction window.
///
/// `body_after_prefix` subtracts this from later active-context usage. It is
/// not the growth itself; server-observed usage replaces estimated
/// resume/recompute baselines when available.
prefill_input_tokens: Option<AutoCompactWindowPrefill>,
}
impl AutoCompactWindow {
pub(super) fn new() -> Self {
Self {
ordinal: 1,
prefill_input_tokens: None,
}
}
pub(super) fn clear_prefill(&mut self) {
self.prefill_input_tokens = None;
}
pub(super) fn start_next(&mut self) {
self.ordinal = self.ordinal.saturating_add(1);
self.clear_prefill();
}
/// Records the request-input side of the first server usage sample. The
/// sampled output from that response is body growth and should remain
/// counted against the scoped auto-compact budget.
pub(super) fn ensure_server_observed_prefill_from_usage(&mut self, usage: &TokenUsage) {
if matches!(
self.prefill_input_tokens,
Some(AutoCompactWindowPrefill::ServerObserved(_))
) {
return;
}
self.prefill_input_tokens = Some(AutoCompactWindowPrefill::ServerObserved(
usage.input_tokens.max(0),
));
}
pub(super) fn set_estimated_prefill(&mut self, tokens: i64) {
if matches!(
self.prefill_input_tokens,
Some(AutoCompactWindowPrefill::ServerObserved(_))
) {
return;
}
self.prefill_input_tokens = Some(AutoCompactWindowPrefill::Estimated(tokens.max(0)));
}
pub(super) fn snapshot(&self) -> AutoCompactWindowSnapshot {
let prefill_input_tokens = match self.prefill_input_tokens {
Some(AutoCompactWindowPrefill::ServerObserved(tokens))
| Some(AutoCompactWindowPrefill::Estimated(tokens)) => Some(tokens),
None => None,
};
AutoCompactWindowSnapshot {
ordinal: self.ordinal,
prefill_input_tokens,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn tracks_prefill_and_window_boundaries() {
let mut window = AutoCompactWindow::new();
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: None,
}
);
window.set_estimated_prefill(/*tokens*/ 150);
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: Some(150),
}
);
window.ensure_server_observed_prefill_from_usage(&TokenUsage {
input_tokens: 120,
total_tokens: 170,
..Default::default()
});
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: Some(120),
}
);
window.ensure_server_observed_prefill_from_usage(&TokenUsage {
input_tokens: 130,
total_tokens: 180,
..Default::default()
});
window.set_estimated_prefill(/*tokens*/ 90);
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: Some(120),
}
);
window.start_next();
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 2,
prefill_input_tokens: None,
}
);
}
}
+2
View File
@@ -1,7 +1,9 @@
mod auto_compact_window;
mod service;
mod session;
mod turn;
pub(crate) use auto_compact_window::AutoCompactWindowSnapshot;
pub(crate) use service::SessionServices;
pub(crate) use session::SessionState;
pub(crate) use turn::ActiveTurn;
+26 -13
View File
@@ -3,9 +3,10 @@
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use std::collections::HashMap;
use std::collections::HashSet;
use super::auto_compact_window::AutoCompactWindow;
use super::auto_compact_window::AutoCompactWindowSnapshot;
use crate::context_manager::ContextManager;
use crate::session::PreviousTurnSettings;
use crate::session::session::SessionConfiguration;
@@ -22,12 +23,13 @@ pub(crate) struct SessionState {
pub(crate) history: ContextManager,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
pub(crate) server_reasoning_included: bool,
pub(crate) dependency_env: HashMap<String, String>,
pub(crate) mcp_dependency_prompted: HashSet<String>,
/// Settings used by the latest regular user turn, used for turn-to-turn
/// model/realtime handling on subsequent regular turns (including full-context
/// reinjection after resume or `/compact`).
previous_turn_settings: Option<PreviousTurnSettings>,
/// Runtime accounting state for the active auto-compaction window.
auto_compact_window: AutoCompactWindow,
/// Startup prewarmed session prepared during session initialization.
pub(crate) startup_prewarm: Option<SessionStartupPrewarmHandle>,
pub(crate) active_connector_selection: HashSet<String>,
@@ -45,9 +47,9 @@ impl SessionState {
history,
latest_rate_limits: None,
server_reasoning_included: false,
dependency_env: HashMap::new(),
mcp_dependency_prompted: HashSet::new(),
previous_turn_settings: None,
auto_compact_window: AutoCompactWindow::new(),
startup_prewarm: None,
active_connector_selection: HashSet::new(),
pending_session_start_source: None,
@@ -97,6 +99,7 @@ impl SessionState {
self.history.replace(items);
self.history
.set_reference_context_item(reference_context_item);
self.auto_compact_window.clear_prefill();
}
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
@@ -120,6 +123,26 @@ impl SessionState {
self.history.update_token_info(usage, model_context_window);
}
pub(crate) fn ensure_auto_compact_window_server_prefill_from_usage(
&mut self,
usage: &TokenUsage,
) {
self.auto_compact_window
.ensure_server_observed_prefill_from_usage(usage);
}
pub(crate) fn set_auto_compact_window_estimated_prefill(&mut self, tokens: i64) {
self.auto_compact_window.set_estimated_prefill(tokens);
}
pub(crate) fn start_next_auto_compact_window(&mut self) {
self.auto_compact_window.start_next();
}
pub(crate) fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
self.auto_compact_window.snapshot()
}
pub(crate) fn token_info(&self) -> Option<TokenUsageInfo> {
self.history.token_info()
}
@@ -165,16 +188,6 @@ impl SessionState {
self.mcp_dependency_prompted.clone()
}
pub(crate) fn set_dependency_env(&mut self, values: HashMap<String, String>) {
for (key, value) in values {
self.dependency_env.insert(key, value);
}
}
pub(crate) fn dependency_env(&self) -> HashMap<String, String> {
self.dependency_env.clone()
}
pub(crate) fn set_session_startup_prewarm(
&mut self,
startup_prewarm: SessionStartupPrewarmHandle,
+19
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::session::tests::make_session_configuration_for_tests;
use crate::state::AutoCompactWindowSnapshot;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use pretty_assertions::assert_eq;
@@ -61,6 +62,24 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
);
}
#[tokio::test]
async fn replace_history_clears_auto_compact_window_prefill_without_advancing() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.start_next_auto_compact_window();
state.set_auto_compact_window_estimated_prefill(/*tokens*/ 100);
state.replace_history(Vec::new(), /*reference_context_item*/ None);
assert_eq!(
state.auto_compact_window_snapshot(),
AutoCompactWindowSnapshot {
ordinal: 2,
prefill_input_tokens: None,
}
);
}
#[tokio::test]
async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_bucket() {
let session_configuration = make_session_configuration_for_tests().await;
+3 -39
View File
@@ -11,7 +11,6 @@ use tokio_util::task::AbortOnDropHandle;
use codex_extension_api::ExtensionData;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
@@ -20,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use rmcp::model::RequestId;
use tokio::sync::oneshot;
use crate::session::TurnInputQueue;
use crate::session::turn_context::TurnContext;
use crate::tasks::AnySessionTask;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -115,7 +115,7 @@ pub(crate) struct TurnState {
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
pending_input: Vec<ResponseInputItem>,
pub(crate) pending_input: TurnInputQueue,
mailbox_delivery_phase: MailboxDeliveryPhase,
granted_permissions: Option<AdditionalPermissionProfile>,
strict_auto_review_enabled: bool,
@@ -146,13 +146,12 @@ impl TurnState {
self.pending_approvals.remove(key)
}
pub(crate) fn clear_pending(&mut self) {
pub(crate) fn clear_pending_waiters(&mut self) {
self.pending_approvals.clear();
self.pending_request_permissions.clear();
self.pending_user_input.clear();
self.pending_elicitations.clear();
self.pending_dynamic_tools.clear();
self.pending_input.clear();
}
pub(crate) fn insert_pending_request_permissions(
@@ -220,33 +219,6 @@ impl TurnState {
self.pending_dynamic_tools.remove(key)
}
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
self.pending_input.push(input);
}
pub(crate) fn prepend_pending_input(&mut self, mut input: Vec<ResponseInputItem>) {
if input.is_empty() {
return;
}
input.append(&mut self.pending_input);
self.pending_input = input;
}
pub(crate) fn take_pending_input(&mut self) -> Vec<ResponseInputItem> {
if self.pending_input.is_empty() {
Vec::with_capacity(0)
} else {
let mut ret = Vec::new();
std::mem::swap(&mut ret, &mut self.pending_input);
ret
}
}
pub(crate) fn has_pending_input(&self) -> bool {
!self.pending_input.is_empty()
}
pub(crate) fn accept_mailbox_delivery_for_current_turn(&mut self) {
self.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn);
}
@@ -276,11 +248,3 @@ impl TurnState {
self.strict_auto_review_enabled
}
}
impl ActiveTurn {
/// Clear any pending approvals and input buffered for the current turn.
pub(crate) async fn clear_pending(&self) {
let mut ts = self.turn_state.lock().await;
ts.clear_pending();
}
}
+7 -2
View File
@@ -158,7 +158,8 @@ pub(crate) async fn record_completed_response_item_with_finalized_facts(
|facts| facts.defers_mailbox_delivery_to_next_turn,
);
if defers_mailbox_delivery {
sess.defer_mailbox_delivery_to_next_turn(&turn_context.sub_id)
sess.input_queue
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &turn_context.sub_id)
.await;
}
mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await;
@@ -351,7 +352,11 @@ pub(crate) async fn handle_output_item_done(
// The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
Ok(Some(call)) => {
ctx.sess
.accept_mailbox_delivery_for_current_turn(&ctx.turn_context.sub_id)
.input_queue
.accept_mailbox_delivery_for_current_turn(
&ctx.sess.active_turn,
&ctx.turn_context.sub_id,
)
.await;
let payload_preview = call.payload.log_payload().into_owned();
@@ -268,8 +268,8 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() {
session.services.extensions = Arc::new(builder.build());
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let router = Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
let router = Arc::new(ToolRouter::from_turn_context(
&turn_context,
crate::tools::router::ToolRouterParams {
mcp_tools: None,
deferred_mcp_tools: None,
+25 -19
View File
@@ -4,38 +4,44 @@ use codex_protocol::protocol::TurnAbortReason;
use crate::session::session::Session;
impl Session {
pub(super) fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) {
pub(super) async fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) {
for contributor in self.services.extensions.turn_lifecycle_contributors() {
contributor.on_turn_start(codex_extension_api::TurnStartInput {
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
turn_store,
});
contributor
.on_turn_start(codex_extension_api::TurnStartInput {
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
turn_store,
})
.await;
}
}
pub(super) fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) {
pub(super) async fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) {
for contributor in self.services.extensions.turn_lifecycle_contributors() {
contributor.on_turn_stop(codex_extension_api::TurnStopInput {
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
turn_store,
});
contributor
.on_turn_stop(codex_extension_api::TurnStopInput {
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
turn_store,
})
.await;
}
}
pub(super) fn emit_turn_abort_lifecycle(
pub(super) async fn emit_turn_abort_lifecycle(
&self,
reason: TurnAbortReason,
turn_store: &ExtensionData,
) {
for contributor in self.services.extensions.turn_lifecycle_contributors() {
contributor.on_turn_abort(codex_extension_api::TurnAbortInput {
reason: reason.clone(),
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
turn_store,
});
contributor
.on_turn_abort(codex_extension_api::TurnAbortInput {
reason: reason.clone(),
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
turn_store,
})
.await;
}
}
}
+53 -36
View File
@@ -24,10 +24,10 @@ use tracing::warn;
use crate::config::Config;
use crate::context::ContextualUserFragment;
use crate::goals::GoalRuntimeEvent;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::inspect_pending_input;
use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::state::ActiveTurn;
@@ -42,7 +42,6 @@ use codex_otel::TURN_MEMORY_METRIC;
use codex_otel::TURN_NETWORK_PROXY_METRIC;
use codex_otel::TURN_TOKEN_USAGE_METRIC;
use codex_otel::TURN_TOOL_CALL_METRIC;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
@@ -347,25 +346,28 @@ impl Session {
{
warn!("failed to apply goal runtime turn-start event: {err}");
}
let queued_response_items = self.take_queued_response_items_for_next_turn().await;
let mailbox_items = self.get_pending_input().await;
let queued_response_items = self
.input_queue
.take_queued_response_items_for_next_turn()
.await;
let mailbox_items = self.input_queue.get_pending_input(&self.active_turn).await;
let turn_state = {
let mut active = self.active_turn.lock().await;
let turn = active.get_or_insert_with(ActiveTurn::default);
debug_assert!(turn.tasks.is_empty());
Arc::clone(&turn.turn_state)
};
{
let mut turn_state = turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
for item in queued_response_items {
turn_state.push_pending_input(item);
}
for item in mailbox_items {
turn_state.push_pending_input(item);
}
}
self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref());
turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start;
let mut pending_items = queued_response_items
.into_iter()
.map(TurnInput::ResponseInputItem)
.collect::<Vec<_>>();
pending_items.extend(mailbox_items);
self.input_queue
.extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items)
.await;
self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref())
.await;
let turn_extension_data = Arc::clone(&turn_context.extension_data);
let mut active = self.active_turn.lock().await;
@@ -467,8 +469,11 @@ impl Session {
self: &Arc<Self>,
sub_id: String,
) {
if !self.has_queued_response_items_for_next_turn().await
&& !self.has_trigger_turn_mailbox_items().await
if !self
.input_queue
.has_queued_response_items_for_next_turn()
.await
&& !self.input_queue.has_trigger_turn_mailbox_items().await
{
return;
}
@@ -505,13 +510,13 @@ impl Session {
}
if let Some(turn_context) = turn_context.as_deref() {
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref());
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
.await;
}
if (aborted_turn || reason == TurnAbortReason::Interrupted)
&& let Err(err) = self
.goal_runtime_apply(GoalRuntimeEvent::TaskAborted {
turn_context: turn_context.as_deref(),
reason: reason.clone(),
})
.await
{
@@ -520,7 +525,7 @@ impl Session {
if let Some(active_turn) = active_turn_to_clear {
// Let interrupted tasks observe cancellation before dropping pending approvals, or an
// in-flight approval wait can surface as a model-visible rejection before TurnAborted.
active_turn.clear_pending().await;
self.input_queue.clear_pending(&active_turn).await;
}
if reason == TurnAbortReason::Interrupted && aborted_turn {
self.maybe_start_turn_for_pending_work().await;
@@ -553,12 +558,12 @@ impl Session {
self.handle_task_abort(task, reason.clone()).await;
}
if let Some(turn_context) = turn_context.as_deref() {
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref());
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
.await;
}
if let Err(err) = self
.goal_runtime_apply(GoalRuntimeEvent::TaskAborted {
turn_context: turn_context.as_deref(),
reason: reason.clone(),
})
.await
{
@@ -566,7 +571,7 @@ impl Session {
}
// Let interrupted tasks observe cancellation before dropping pending approvals, or an
// in-flight approval wait can surface as a model-visible rejection before TurnAborted.
active_turn.clear_pending().await;
self.input_queue.clear_pending(&active_turn).await;
if reason == TurnAbortReason::Interrupted {
self.maybe_start_turn_for_pending_work().await;
@@ -584,7 +589,7 @@ impl Session {
.turn_metadata_state
.cancel_git_enrichment_task();
let mut pending_input = Vec::<ResponseInputItem>::new();
let mut pending_input = Vec::<TurnInput>::new();
let mut should_clear_active_turn = false;
let mut token_usage_at_turn_start = None;
let mut turn_had_memory_citation = false;
@@ -608,23 +613,34 @@ impl Session {
}
};
if let Some(turn_state) = turn_state.as_ref() {
let mut ts = turn_state.lock().await;
pending_input = ts.take_pending_input();
pending_input = self
.input_queue
.take_pending_input_for_turn_state(turn_state.as_ref())
.await;
let ts = turn_state.lock().await;
turn_had_memory_citation = ts.has_memory_citation;
turn_tool_calls = ts.tool_calls;
token_usage_at_turn_start = Some(ts.token_usage_at_turn_start.clone());
}
if !pending_input.is_empty() {
for pending_input_item in pending_input {
match inspect_pending_input(self, &turn_context, pending_input_item).await {
PendingInputHookDisposition::Accepted(pending_input) => {
record_pending_input(self, &turn_context, *pending_input).await;
}
PendingInputHookDisposition::Blocked {
additional_contexts,
} => {
record_additional_contexts(self, &turn_context, additional_contexts).await;
}
let hook_outcome =
inspect_pending_input(self, &turn_context, &pending_input_item).await;
if hook_outcome.should_stop {
record_additional_contexts(
self,
&turn_context,
hook_outcome.additional_contexts,
)
.await;
} else {
record_pending_input(
self,
&turn_context,
pending_input_item,
hook_outcome.additional_contexts,
)
.await;
}
}
}
@@ -756,7 +772,8 @@ impl Session {
.time_to_first_token_ms()
.await;
if should_clear_active_turn {
self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref());
self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref())
.await;
}
if let Err(err) = self
.goal_runtime_apply(GoalRuntimeEvent::TurnFinished {
+1 -1
View File
@@ -80,7 +80,7 @@ impl SessionTask for RegularTask {
)
.instrument(run_turn_span.clone())
.await;
if !sess.has_pending_input().await {
if !sess.input_queue.has_pending_input(&sess.active_turn).await {
return last_agent_message;
}
next_input = Vec::new();
+1 -1
View File
@@ -1228,7 +1228,7 @@ impl ThreadManagerState {
.finalize_thread_spawn(codex, thread_id, tracked_session_source)
.await?;
if is_resumed_thread {
new_thread.thread.emit_thread_resume_lifecycle();
new_thread.thread.emit_thread_resume_lifecycle().await;
if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await {
warn!("failed to apply goal resume runtime effects: {err}");
}

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