mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
Merge dev/winston/mitm-ca-trust into dev/winston/credentialed-routes-discovery
This commit is contained in:
@@ -96,7 +96,7 @@ fn agent_nickname_candidates(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
|
||||
fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str()
|
||||
{
|
||||
@@ -120,13 +120,30 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other,
|
||||
) => false,
|
||||
// A forked child gets its own runtime config, including spawned-agent
|
||||
// instructions, so it must establish a fresh context diff baseline.
|
||||
RolloutItem::TurnContext(_) => false,
|
||||
// Full-history forks preserve the cached prompt prefix and can keep diffing
|
||||
// from the parent's durable baseline. Truncated forks drop part of that prompt,
|
||||
// so they must rebuild context on their first child turn.
|
||||
RolloutItem::TurnContext(_) => preserve_reference_context_item,
|
||||
RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
if role != "developer" {
|
||||
return false;
|
||||
}
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
usage_hint_texts
|
||||
.iter()
|
||||
.any(|usage_hint_text| usage_hint_text == text)
|
||||
}
|
||||
|
||||
/// Control-plane handle for multi-agent operations.
|
||||
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
|
||||
/// spawn new agents and the inter-agent communication layer.
|
||||
@@ -396,16 +413,26 @@ impl AgentControl {
|
||||
forked_rollout_items =
|
||||
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
|
||||
}
|
||||
// MultiAgentV2 root/subagent usage hints are injected as standalone developer
|
||||
// messages at thread start. When forking history, drop hints from the parent
|
||||
// so the child gets a fresh hint that matches its own session source/config.
|
||||
let multi_agent_v2_usage_hint_texts_to_filter: Vec<String> =
|
||||
if let Some(parent_thread) = parent_thread.as_ref() {
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.configured_multi_agent_v2_usage_hint_texts()
|
||||
.await
|
||||
if parent_thread.enabled(Feature::MultiAgentV2) {
|
||||
let parent_config = parent_thread.codex.session.get_config().await;
|
||||
[
|
||||
parent_config
|
||||
.multi_agent_v2
|
||||
.root_agent_usage_hint_text
|
||||
.clone(),
|
||||
parent_config
|
||||
.multi_agent_v2
|
||||
.subagent_usage_hint_text
|
||||
.clone(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else if config.features.enabled(Feature::MultiAgentV2) {
|
||||
[
|
||||
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
|
||||
@@ -417,19 +444,41 @@ impl AgentControl {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory);
|
||||
forked_rollout_items.retain(|item| {
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item
|
||||
&& role == "developer"
|
||||
&& let [ContentItem::InputText { text }] = content.as_slice()
|
||||
&& multi_agent_v2_usage_hint_texts_to_filter
|
||||
.iter()
|
||||
.any(|usage_hint_text| usage_hint_text == text)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
keep_forked_rollout_item(item)
|
||||
keep_forked_rollout_item(item, preserve_reference_context_item)
|
||||
&& !matches!(
|
||||
item,
|
||||
RolloutItem::ResponseItem(response_item)
|
||||
if is_multi_agent_v2_usage_hint_message(
|
||||
response_item,
|
||||
&multi_agent_v2_usage_hint_texts_to_filter,
|
||||
)
|
||||
)
|
||||
});
|
||||
for item in &mut forked_rollout_items {
|
||||
if let RolloutItem::Compacted(compacted) = item
|
||||
&& let Some(replacement_history) = compacted.replacement_history.as_mut()
|
||||
{
|
||||
replacement_history.retain(|response_item| {
|
||||
!is_multi_agent_v2_usage_hint_message(
|
||||
response_item,
|
||||
&multi_agent_v2_usage_hint_texts_to_filter,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
if preserve_reference_context_item
|
||||
&& config.features.enabled(Feature::MultiAgentV2)
|
||||
&& let Some(subagent_usage_hint_text) =
|
||||
config.multi_agent_v2.subagent_usage_hint_text.clone()
|
||||
&& let Some(subagent_usage_hint_message) =
|
||||
crate::context_manager::updates::build_developer_update_item(vec![
|
||||
subagent_usage_hint_text,
|
||||
])
|
||||
{
|
||||
forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message));
|
||||
}
|
||||
|
||||
state
|
||||
.fork_thread_with_source(
|
||||
@@ -499,13 +548,12 @@ impl AgentControl {
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
match self
|
||||
.resume_single_agent_from_rollout(
|
||||
config.clone(),
|
||||
child_thread_id,
|
||||
child_session_source,
|
||||
)
|
||||
.await
|
||||
match Box::pin(self.resume_single_agent_from_rollout(
|
||||
config.clone(),
|
||||
child_thread_id,
|
||||
child_session_source,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
@@ -1117,24 +1165,16 @@ impl AgentControl {
|
||||
let state = self.upgrade()?;
|
||||
let mut children_by_parent = HashMap::<ThreadId, Vec<(ThreadId, AgentMetadata)>>::new();
|
||||
|
||||
for thread_id in state.list_thread_ids().await {
|
||||
let Ok(thread) = state.get_thread(thread_id).await else {
|
||||
continue;
|
||||
};
|
||||
let snapshot = thread.config_snapshot().await;
|
||||
let Some(parent_thread_id) = thread_spawn_parent_thread_id(&snapshot.session_source)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (parent_thread_id, child_thread_id) in state.list_live_thread_spawn_edges().await {
|
||||
children_by_parent
|
||||
.entry(parent_thread_id)
|
||||
.or_default()
|
||||
.push((
|
||||
thread_id,
|
||||
child_thread_id,
|
||||
self.state
|
||||
.agent_metadata_for_thread(thread_id)
|
||||
.agent_metadata_for_thread(child_thread_id)
|
||||
.unwrap_or(AgentMetadata {
|
||||
agent_id: Some(thread_id),
|
||||
agent_id: Some(child_thread_id),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
@@ -1235,7 +1275,9 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
|
||||
UserInput::LocalImage { path, .. } => {
|
||||
format!("[local_image:{}]", path.display())
|
||||
}
|
||||
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
|
||||
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
@@ -270,6 +271,7 @@ async fn get_status_returns_not_found_without_manager() {
|
||||
async fn on_event_updates_status_from_task_started() {
|
||||
let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -442,6 +444,7 @@ async fn send_input_submits_user_message() {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
@@ -486,7 +489,13 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if thread.codex.session.has_pending_input().await {
|
||||
if thread
|
||||
.codex
|
||||
.session
|
||||
.input_queue
|
||||
.has_pending_input(&thread.codex.session.active_turn)
|
||||
.await
|
||||
{
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
@@ -589,6 +598,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
@@ -669,6 +679,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let parent_reference_context_item = turn_context.to_turn_context_item();
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.persist_rollout_items(&[RolloutItem::TurnContext(
|
||||
parent_reference_context_item.clone(),
|
||||
)])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
@@ -720,11 +738,26 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
phase: None,
|
||||
},
|
||||
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Child subagent guidance.".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
history.raw_items(),
|
||||
&expected_history,
|
||||
"forked child history should keep only parent user messages and assistant final answers"
|
||||
"full-history forked child history should replace parent usage hints with the child subagent hint while filtering non-final assistant/tool chatter"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(child_thread.codex.session.reference_context_item().await)
|
||||
.expect("serialize child reference context item"),
|
||||
serde_json::to_value(Some(parent_reference_context_item))
|
||||
.expect("serialize expected reference context item"),
|
||||
"full-history forked child should preserve the parent diff baseline"
|
||||
);
|
||||
|
||||
let expected = (
|
||||
@@ -737,6 +770,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
@@ -757,6 +791,124 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let mut parent_config = harness.config.clone();
|
||||
let _ = parent_config.features.enable(Feature::MultiAgentV2);
|
||||
parent_config.multi_agent_v2.root_agent_usage_hint_text =
|
||||
Some("Parent root guidance.".to_string());
|
||||
parent_config.multi_agent_v2.subagent_usage_hint_text =
|
||||
Some("Parent subagent guidance.".to_string());
|
||||
let mut child_config = harness.config.clone();
|
||||
let _ = child_config.features.enable(Feature::MultiAgentV2);
|
||||
child_config.multi_agent_v2.root_agent_usage_hint_text =
|
||||
Some("Child root guidance.".to_string());
|
||||
child_config.multi_agent_v2.subagent_usage_hint_text =
|
||||
Some("Child subagent guidance.".to_string());
|
||||
let new_thread = harness
|
||||
.manager
|
||||
.start_thread(parent_config)
|
||||
.await
|
||||
.expect("start parent thread");
|
||||
let parent_thread_id = new_thread.thread_id;
|
||||
let parent_thread = new_thread.thread;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string();
|
||||
let replacement_history = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "compacted parent summary".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Parent root guidance.".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
];
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.persist_rollout_items(&[
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(replacement_history),
|
||||
}),
|
||||
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
|
||||
RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)),
|
||||
])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.flush_rollout()
|
||||
.await
|
||||
.expect("parent rollout should flush");
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
child_config,
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
|
||||
fork_mode: Some(SpawnAgentForkMode::FullHistory),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should sanitize compacted usage hints")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "compacted parent summary"),
|
||||
"forked child history should retain compacted non-hint content"
|
||||
);
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "Parent root guidance."),
|
||||
"forked child history should strip stale parent hints from compacted replacement history"
|
||||
);
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "Child subagent guidance."),
|
||||
"full-history forked child should add the child subagent hint after compacted-history sanitization"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
@@ -873,6 +1025,13 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
|
||||
&[spawn_agent_call(&parent_spawn_call_id)],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.persist_rollout_items(&[RolloutItem::TurnContext(
|
||||
spawn_turn_context.to_turn_context_item(),
|
||||
)])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
@@ -930,6 +1089,116 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
|
||||
history_contains_text(history.raw_items(), "current parent task"),
|
||||
"forked child history should keep the parent user message from the requested last-N turn window"
|
||||
);
|
||||
assert!(
|
||||
child_thread
|
||||
.codex
|
||||
.session
|
||||
.reference_context_item()
|
||||
.await
|
||||
.is_none(),
|
||||
"last-N forked child should rebuild context after truncating the cached prefix"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let mut parent_config = harness.config.clone();
|
||||
let _ = parent_config.features.enable(Feature::MultiAgentV2);
|
||||
parent_config.multi_agent_v2.root_agent_usage_hint_text =
|
||||
Some("Parent root guidance.".to_string());
|
||||
let mut child_config = harness.config.clone();
|
||||
let _ = child_config.features.enable(Feature::MultiAgentV2);
|
||||
child_config.multi_agent_v2.subagent_usage_hint_text =
|
||||
Some("Child subagent guidance.".to_string());
|
||||
let new_thread = harness
|
||||
.manager
|
||||
.start_thread(parent_config)
|
||||
.await
|
||||
.expect("start parent thread");
|
||||
let parent_thread_id = new_thread.thread_id;
|
||||
let parent_thread = new_thread.thread;
|
||||
parent_thread
|
||||
.inject_user_message_without_turn("parent task".to_string())
|
||||
.await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-last-n-usage-hints".to_string();
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(
|
||||
turn_context.as_ref(),
|
||||
&[
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Parent root guidance.".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
spawn_agent_call(&parent_spawn_call_id),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.flush_rollout()
|
||||
.await
|
||||
.expect("parent rollout should flush");
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
child_config,
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
|
||||
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("bounded forked spawn should sanitize parent usage hints")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "parent task"),
|
||||
"bounded fork should retain the requested recent parent turn"
|
||||
);
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "Parent root guidance."),
|
||||
"bounded fork should strip stale parent root hints before the child rebuilds startup context"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
@@ -1855,6 +2124,64 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() {
|
||||
let (_home, config) = test_config().await;
|
||||
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.to_path_buf(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*state_db*/ None,
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
let parent_thread_id = manager
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("parent should start")
|
||||
.thread_id;
|
||||
|
||||
let child_thread_id = control
|
||||
.spawn_agent(
|
||||
config.clone(),
|
||||
text_input("hello child"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("child spawn should succeed");
|
||||
let grandchild_thread_id = control
|
||||
.spawn_agent(
|
||||
config,
|
||||
text_input("hello grandchild"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("grandchild spawn should succeed");
|
||||
|
||||
let mut subtree_thread_ids = manager
|
||||
.list_agent_subtree_thread_ids(parent_thread_id)
|
||||
.await
|
||||
.expect("live subtree should load");
|
||||
subtree_thread_ids.sort_by_key(ToString::to_string);
|
||||
let mut expected_subtree_thread_ids =
|
||||
vec![parent_thread_id, child_thread_id, grandchild_thread_id];
|
||||
expected_subtree_thread_ids.sort_by_key(ToString::to_string);
|
||||
|
||||
assert_eq!(subtree_thread_ids, expected_subtree_thread_ids);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_agent_tree_closes_live_descendants() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! Roles are selected at spawn time and are loaded with the same config machinery as
|
||||
//! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a
|
||||
//! high-precedence layer, and preserves the caller's current profile/provider unless the role
|
||||
//! explicitly takes ownership of model selection. It does not decide when to spawn a sub-agent or
|
||||
//! which role to use; the multi-agent tool handler owns that orchestration.
|
||||
//! high-precedence layer, and preserves the caller's current provider and service tier unless the
|
||||
//! role layer sets them. It does not decide when to spawn a sub-agent or which role to use; the
|
||||
//! multi-agent tool handler owns that orchestration.
|
||||
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::Config;
|
||||
@@ -29,14 +29,12 @@ use toml::Value as TomlValue;
|
||||
pub const DEFAULT_ROLE_NAME: &str = "default";
|
||||
const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available";
|
||||
|
||||
/// Applies a named role layer to `config` while preserving caller-owned model selection.
|
||||
/// Applies a named role layer to `config` while preserving caller-owned provider settings.
|
||||
///
|
||||
/// The role layer is inserted at session-flag precedence so it can override persisted config, but
|
||||
/// the caller's current `profile` and `model_provider` remain sticky runtime choices unless the
|
||||
/// role explicitly sets `profile`, explicitly sets `model_provider`, or rewrites the active
|
||||
/// profile's `model_provider` in place. Rebuilding the config without those overrides would make a
|
||||
/// spawned agent silently fall back to the default provider, which is the bug this preservation
|
||||
/// logic avoids.
|
||||
/// the caller's current `model_provider` and `service_tier` remain sticky runtime choices unless
|
||||
/// the role explicitly sets the corresponding top-level config key. Rebuilding the config without
|
||||
/// those overrides would make a spawned agent silently fall back to default settings.
|
||||
pub(crate) async fn apply_role_to_config(
|
||||
config: &mut Config,
|
||||
role_name: Option<&str>,
|
||||
@@ -71,14 +69,14 @@ async fn apply_role_to_config_inner(
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let (preserve_current_profile, preserve_current_provider) =
|
||||
preservation_policy(config, &role_layer_toml);
|
||||
let preserve_current_provider = role_layer_toml.get("model_provider").is_none();
|
||||
let preserve_current_service_tier = role_layer_toml.get("service_tier").is_none();
|
||||
|
||||
*config = reload::build_next_config(
|
||||
config,
|
||||
role_layer_toml,
|
||||
preserve_current_profile,
|
||||
preserve_current_provider,
|
||||
preserve_current_service_tier,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -128,71 +126,38 @@ pub(crate) fn resolve_role_config<'a>(
|
||||
.or_else(|| built_in::configs().get(role_name))
|
||||
}
|
||||
|
||||
fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) {
|
||||
let role_selects_provider = role_layer_toml.get("model_provider").is_some();
|
||||
let role_selects_profile = role_layer_toml.get("profile").is_some();
|
||||
let role_updates_active_profile_provider = config
|
||||
.active_profile
|
||||
.as_ref()
|
||||
.and_then(|active_profile| {
|
||||
role_layer_toml
|
||||
.get("profiles")
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|profiles| profiles.get(active_profile))
|
||||
.and_then(TomlValue::as_table)
|
||||
.map(|profile| profile.contains_key("model_provider"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let preserve_current_profile = !role_selects_provider && !role_selects_profile;
|
||||
let preserve_current_provider =
|
||||
preserve_current_profile && !role_updates_active_profile_provider;
|
||||
(preserve_current_profile, preserve_current_provider)
|
||||
}
|
||||
|
||||
mod reload {
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn build_next_config(
|
||||
config: &Config,
|
||||
role_layer_toml: TomlValue,
|
||||
preserve_current_profile: bool,
|
||||
preserve_current_provider: bool,
|
||||
preserve_current_service_tier: bool,
|
||||
) -> anyhow::Result<Config> {
|
||||
let active_profile_name = preserve_current_profile
|
||||
.then_some(config.active_profile.as_deref())
|
||||
.flatten();
|
||||
let config_layer_stack =
|
||||
build_config_layer_stack(config, &role_layer_toml, active_profile_name)?;
|
||||
let mut merged_config = deserialize_effective_config(config, &config_layer_stack)?;
|
||||
if preserve_current_profile {
|
||||
merged_config.profile = None;
|
||||
}
|
||||
let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?;
|
||||
let merged_config = deserialize_effective_config(config, &config_layer_stack)?;
|
||||
|
||||
let mut next_config = Config::load_config_with_layer_stack(
|
||||
let next_config = Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
merged_config,
|
||||
reload_overrides(config, preserve_current_provider),
|
||||
reload_overrides(
|
||||
config,
|
||||
preserve_current_provider,
|
||||
preserve_current_service_tier,
|
||||
),
|
||||
config.codex_home.clone(),
|
||||
config_layer_stack,
|
||||
)
|
||||
.await?;
|
||||
if preserve_current_profile {
|
||||
next_config.active_profile = config.active_profile.clone();
|
||||
}
|
||||
Ok(next_config)
|
||||
}
|
||||
|
||||
fn build_config_layer_stack(
|
||||
config: &Config,
|
||||
role_layer_toml: &TomlValue,
|
||||
active_profile_name: Option<&str>,
|
||||
) -> anyhow::Result<ConfigLayerStack> {
|
||||
let mut layers = existing_layers(config);
|
||||
if let Some(resolved_profile_layer) =
|
||||
resolved_profile_layer(config, &layers, role_layer_toml, active_profile_name)?
|
||||
{
|
||||
insert_layer(&mut layers, resolved_profile_layer);
|
||||
}
|
||||
insert_layer(&mut layers, role_layer(role_layer_toml.clone()));
|
||||
Ok(ConfigLayerStack::new(
|
||||
layers,
|
||||
@@ -201,34 +166,6 @@ mod reload {
|
||||
)?)
|
||||
}
|
||||
|
||||
fn resolved_profile_layer(
|
||||
config: &Config,
|
||||
existing_layers: &[ConfigLayerEntry],
|
||||
role_layer_toml: &TomlValue,
|
||||
active_profile_name: Option<&str>,
|
||||
) -> anyhow::Result<Option<ConfigLayerEntry>> {
|
||||
let Some(active_profile_name) = active_profile_name else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut layers = existing_layers.to_vec();
|
||||
insert_layer(&mut layers, role_layer(role_layer_toml.clone()));
|
||||
let merged_config = deserialize_effective_config(
|
||||
config,
|
||||
&ConfigLayerStack::new(
|
||||
layers,
|
||||
config.config_layer_stack.requirements().clone(),
|
||||
config.config_layer_stack.requirements_toml().clone(),
|
||||
)?,
|
||||
)?;
|
||||
let resolved_profile =
|
||||
merged_config.get_config_profile(Some(active_profile_name.to_string()))?;
|
||||
Ok(Some(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
TomlValue::try_from(resolved_profile)?,
|
||||
)))
|
||||
}
|
||||
|
||||
fn deserialize_effective_config(
|
||||
config: &Config,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
@@ -261,10 +198,15 @@ mod reload {
|
||||
ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml)
|
||||
}
|
||||
|
||||
fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides {
|
||||
fn reload_overrides(
|
||||
config: &Config,
|
||||
preserve_current_provider: bool,
|
||||
preserve_current_service_tier: bool,
|
||||
) -> ConfigOverrides {
|
||||
ConfigOverrides {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()),
|
||||
service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
..Default::default()
|
||||
@@ -323,8 +265,11 @@ pub(crate) mod spawn_tool_spec {
|
||||
let reasoning_effort = role_toml
|
||||
.get("model_reasoning_effort")
|
||||
.and_then(TomlValue::as_str);
|
||||
let service_tier = role_toml
|
||||
.get("service_tier")
|
||||
.and_then(TomlValue::as_str);
|
||||
|
||||
match (model, reasoning_effort) {
|
||||
let model_and_reasoning_note = match (model, reasoning_effort) {
|
||||
(Some(model), Some(reasoning_effort)) => format!(
|
||||
"\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed."
|
||||
),
|
||||
@@ -339,7 +284,15 @@ pub(crate) mod spawn_tool_spec {
|
||||
)
|
||||
}
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
};
|
||||
let service_tier_note = service_tier
|
||||
.map(|service_tier| {
|
||||
format!(
|
||||
"\n- This role's service tier is set to `{service_tier}`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier."
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("{model_and_reasoning_note}{service_tier_note}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("{name}: {{\n{description}{locked_settings_note}\n}}")
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use super::*;
|
||||
use crate::SkillsManager;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::skills_load_input_from_config;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -215,90 +213,13 @@ async fn apply_role_preserves_unspecified_keys() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_role_preserves_active_profile_and_model_provider() {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
tokio::fs::write(
|
||||
home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[model_providers.test-provider]
|
||||
name = "Test Provider"
|
||||
base_url = "https://example.com/v1"
|
||||
env_key = "TEST_PROVIDER_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.test-profile]
|
||||
model_provider = "test-provider"
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.expect("write config.toml");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile: Some("test-profile".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.fallback_cwd(Some(home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
async fn apply_role_reports_explicit_service_tier() {
|
||||
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
|
||||
let role_path = write_role_config(
|
||||
&home,
|
||||
"empty-role.toml",
|
||||
"developer_instructions = \"Stay focused\"",
|
||||
)
|
||||
.await;
|
||||
config.agent_roles.insert(
|
||||
"custom".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
apply_role_to_config(&mut config, Some("custom"))
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
assert_eq!(config.active_profile.as_deref(), Some("test-profile"));
|
||||
assert_eq!(config.model_provider_id, "test-provider");
|
||||
assert_eq!(config.model_provider.name, "Test Provider");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_role_top_level_profile_settings_override_preserved_profile() {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
tokio::fs::write(
|
||||
home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[profiles.base-profile]
|
||||
model = "profile-model"
|
||||
model_reasoning_effort = "low"
|
||||
model_reasoning_summary = "concise"
|
||||
model_verbosity = "low"
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.expect("write config.toml");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile: Some("base-profile".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.fallback_cwd(Some(home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
let role_path = write_role_config(
|
||||
&home,
|
||||
"top-level-profile-settings-role.toml",
|
||||
"tiered-role.toml",
|
||||
r#"developer_instructions = "Stay focused"
|
||||
model = "role-model"
|
||||
model_reasoning_effort = "high"
|
||||
model_reasoning_summary = "detailed"
|
||||
model_verbosity = "high"
|
||||
service_tier = "priority"
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
@@ -315,178 +236,20 @@ model_verbosity = "high"
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
assert_eq!(config.active_profile.as_deref(), Some("base-profile"));
|
||||
assert_eq!(config.model.as_deref(), Some("role-model"));
|
||||
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
|
||||
assert_eq!(
|
||||
config.model_reasoning_summary,
|
||||
Some(ReasoningSummary::Detailed)
|
||||
config.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
assert_eq!(config.model_verbosity, Some(Verbosity::High));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_role_uses_role_profile_instead_of_current_profile() {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
tokio::fs::write(
|
||||
home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[model_providers.base-provider]
|
||||
name = "Base Provider"
|
||||
base_url = "https://base.example.com/v1"
|
||||
env_key = "BASE_PROVIDER_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[model_providers.role-provider]
|
||||
name = "Role Provider"
|
||||
base_url = "https://role.example.com/v1"
|
||||
env_key = "ROLE_PROVIDER_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.base-profile]
|
||||
model_provider = "base-provider"
|
||||
|
||||
[profiles.role-profile]
|
||||
model_provider = "role-provider"
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.expect("write config.toml");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile: Some("base-profile".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.fallback_cwd(Some(home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
async fn apply_role_preserves_existing_service_tier_without_override() {
|
||||
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
|
||||
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||
let role_path = write_role_config(
|
||||
&home,
|
||||
"profile-role.toml",
|
||||
"developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"",
|
||||
)
|
||||
.await;
|
||||
config.agent_roles.insert(
|
||||
"custom".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
apply_role_to_config(&mut config, Some("custom"))
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
assert_eq!(config.active_profile.as_deref(), Some("role-profile"));
|
||||
assert_eq!(config.model_provider_id, "role-provider");
|
||||
assert_eq!(config.model_provider.name, "Role Provider");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
tokio::fs::write(
|
||||
home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[model_providers.base-provider]
|
||||
name = "Base Provider"
|
||||
base_url = "https://base.example.com/v1"
|
||||
env_key = "BASE_PROVIDER_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[model_providers.role-provider]
|
||||
name = "Role Provider"
|
||||
base_url = "https://role.example.com/v1"
|
||||
env_key = "ROLE_PROVIDER_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.base-profile]
|
||||
model_provider = "base-provider"
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.expect("write config.toml");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile: Some("base-profile".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.fallback_cwd(Some(home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
let role_path = write_role_config(
|
||||
&home,
|
||||
"provider-role.toml",
|
||||
"developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"",
|
||||
)
|
||||
.await;
|
||||
config.agent_roles.insert(
|
||||
"custom".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
|
||||
apply_role_to_config(&mut config, Some("custom"))
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
assert_eq!(config.active_profile, None);
|
||||
assert_eq!(config.model_provider_id, "role-provider");
|
||||
assert_eq!(config.model_provider.name, "Role Provider");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_role_uses_active_profile_model_provider_update() {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
tokio::fs::write(
|
||||
home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[model_providers.base-provider]
|
||||
name = "Base Provider"
|
||||
base_url = "https://base.example.com/v1"
|
||||
env_key = "BASE_PROVIDER_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[model_providers.role-provider]
|
||||
name = "Role Provider"
|
||||
base_url = "https://role.example.com/v1"
|
||||
env_key = "ROLE_PROVIDER_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.base-profile]
|
||||
model_provider = "base-provider"
|
||||
model_reasoning_effort = "low"
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.expect("write config.toml");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile: Some("base-profile".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.fallback_cwd(Some(home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
let role_path = write_role_config(
|
||||
&home,
|
||||
"profile-edit-role.toml",
|
||||
"default-tier-role.toml",
|
||||
r#"developer_instructions = "Stay focused"
|
||||
|
||||
[profiles.base-profile]
|
||||
model_provider = "role-provider"
|
||||
model_reasoning_effort = "high"
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
@@ -503,10 +266,10 @@ model_reasoning_effort = "high"
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
assert_eq!(config.active_profile.as_deref(), Some("base-profile"));
|
||||
assert_eq!(config.model_provider_id, "role-provider");
|
||||
assert_eq!(config.model_provider.name, "Role Provider");
|
||||
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
|
||||
assert_eq!(
|
||||
config.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -766,6 +529,31 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_tool_spec_marks_role_locked_service_tier() {
|
||||
let tempdir = TempDir::new().expect("create temp dir");
|
||||
let role_path = tempdir.path().join("tiered.toml");
|
||||
fs::write(
|
||||
&role_path,
|
||||
"developer_instructions = \"Stay fast\"\nservice_tier = \"priority\"\n",
|
||||
)
|
||||
.expect("write role config");
|
||||
let user_defined_roles = BTreeMap::from([(
|
||||
"tiered".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: Some("Stay fast.".to_string()),
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
let spec = spawn_tool_spec::build(&user_defined_roles);
|
||||
|
||||
assert!(spec.contains(
|
||||
"Stay fast.\n- This role's service tier is set to `priority`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_config_file_contents_resolves_explorer_only() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_config::merge_toml_values;
|
||||
use codex_config::project_root_markers_from_config;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
@@ -58,20 +59,34 @@ impl<'a> AgentsMdManager<'a> {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub(crate) fn load_global_instructions(
|
||||
pub(crate) async fn load_global_instructions(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
codex_dir: Option<&AbsolutePathBuf>,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let base = codex_dir?;
|
||||
for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] {
|
||||
let path = base.join(candidate);
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(LoadedAgentsMd {
|
||||
contents: trimmed.to_string(),
|
||||
path,
|
||||
});
|
||||
let data = match fs.read_file(&path, /*sandbox*/ None).await {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue,
|
||||
Err(err) => {
|
||||
startup_warnings.push(format!(
|
||||
"Failed to read global AGENTS.md instructions from `{}`: {err}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
warn_invalid_utf8(&path, &data, "Global", startup_warnings);
|
||||
let contents = String::from_utf8_lossy(&data);
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(LoadedAgentsMd {
|
||||
contents: trimmed.to_string(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -82,16 +97,19 @@ impl<'a> AgentsMdManager<'a> {
|
||||
pub(crate) async fn user_instructions(
|
||||
&self,
|
||||
environment: Option<&Environment>,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let fs = environment?.get_filesystem();
|
||||
self.user_instructions_with_fs(fs.as_ref()).await
|
||||
self.user_instructions_with_fs(fs.as_ref(), startup_warnings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn user_instructions_with_fs(
|
||||
async fn user_instructions_with_fs(
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let agents_md_docs = self.read_agents_md(fs).await;
|
||||
let agents_md_docs = self.read_agents_md(fs, startup_warnings).await;
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
@@ -128,9 +146,15 @@ impl<'a> AgentsMdManager<'a> {
|
||||
|
||||
/// Returns all instruction source files included in the current config.
|
||||
pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Self::load_global_instructions(Some(&self.config.codex_home))
|
||||
.map(|loaded| vec![loaded.path])
|
||||
.unwrap_or_default();
|
||||
let mut global_instruction_warnings = Vec::new();
|
||||
let mut paths = Self::load_global_instructions(
|
||||
LOCAL_FS.as_ref(),
|
||||
Some(&self.config.codex_home),
|
||||
&mut global_instruction_warnings,
|
||||
)
|
||||
.await
|
||||
.map(|loaded| vec![loaded.path])
|
||||
.unwrap_or_default();
|
||||
match self.agents_md_paths(fs).await {
|
||||
Ok(agents_md_paths) => paths.extend(agents_md_paths),
|
||||
Err(err) => {
|
||||
@@ -146,7 +170,11 @@ impl<'a> AgentsMdManager<'a> {
|
||||
/// concatenation of all discovered docs. If no documentation file is found
|
||||
/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as
|
||||
/// `Err` so callers can decide how to handle them.
|
||||
async fn read_agents_md(&self, fs: &dyn ExecutorFileSystem) -> io::Result<Option<String>> {
|
||||
async fn read_agents_md(
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> io::Result<Option<String>> {
|
||||
let max_total = self.config.project_doc_max_bytes;
|
||||
|
||||
if max_total == 0 {
|
||||
@@ -178,6 +206,8 @@ impl<'a> AgentsMdManager<'a> {
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
warn_invalid_utf8(&p, &data, "Project", startup_warnings);
|
||||
|
||||
let size = data.len() as u64;
|
||||
if size > remaining {
|
||||
data.truncate(remaining as usize);
|
||||
@@ -320,6 +350,20 @@ impl<'a> AgentsMdManager<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_invalid_utf8(
|
||||
path: &AbsolutePathBuf,
|
||||
data: &[u8],
|
||||
source: &str,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) {
|
||||
if let Err(err) = std::str::from_utf8(data) {
|
||||
startup_warnings.push(format!(
|
||||
"{source} AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agents_md_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -7,12 +7,14 @@ use core_test_support::PathBufExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
let mut warnings = Vec::new();
|
||||
AgentsMdManager::new(config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref())
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -22,6 +24,19 @@ async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf
|
||||
.await
|
||||
}
|
||||
|
||||
fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) {
|
||||
let path_display = path.display().to_string();
|
||||
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
|
||||
let warning = &warnings[0];
|
||||
assert!(
|
||||
warning.contains(&format!("{source} AGENTS.md instructions"))
|
||||
&& warning.contains(&path_display)
|
||||
&& warning.contains("invalid UTF-8")
|
||||
&& warning.contains("Invalid byte sequences were replaced."),
|
||||
"unexpected invalid UTF-8 warning: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper that returns a `Config` pointing at `root` and using `limit` as
|
||||
/// the maximum number of bytes to embed from AGENTS.md. The caller can
|
||||
/// optionally specify a custom `instructions` string – when `None` the
|
||||
@@ -105,8 +120,9 @@ async fn no_environment_returns_none() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions(/*environment*/ None)
|
||||
.user_instructions(/*environment*/ None, &mut warnings)
|
||||
.await;
|
||||
|
||||
assert_eq!(res, None);
|
||||
@@ -129,6 +145,45 @@ async fn doc_smaller_than_limit_is_returned() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let codex_home_abs = codex_home.abs();
|
||||
let path = codex_home_abs.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
fs::write(&path, b"global\xFF doc").unwrap();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::load_global_instructions(
|
||||
LOCAL_FS.as_ref(),
|
||||
Some(&codex_home_abs),
|
||||
&mut warnings,
|
||||
)
|
||||
.await
|
||||
.expect("global doc expected");
|
||||
|
||||
assert_eq!(loaded.contents, "global\u{FFFD} doc");
|
||||
assert_eq!(loaded.path, path);
|
||||
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("AGENTS.md");
|
||||
fs::write(&path, b"project\xFF doc").unwrap();
|
||||
|
||||
let config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
let mut warnings = Vec::new();
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
|
||||
assert_eq!(res, "project\u{FFFD} doc");
|
||||
let canonical_path = dunce::canonicalize(&path).expect("canonical doc path");
|
||||
assert_invalid_utf8_warning(&warnings, "Project", &canonical_path);
|
||||
}
|
||||
|
||||
/// Oversize file is truncated to `project_doc_max_bytes`.
|
||||
#[tokio::test]
|
||||
async fn doc_larger_than_limit_is_truncated() {
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::compact::content_items_to_text;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
const ARC_MONITOR_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE: &str = "CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE";
|
||||
const CODEX_ARC_MONITOR_TOKEN: &str = "CODEX_ARC_MONITOR_TOKEN";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ArcMonitorOutcome {
|
||||
Ok,
|
||||
SteerModel(String),
|
||||
AskUser(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
struct ArcMonitorRequest {
|
||||
metadata: ArcMonitorMetadata,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
messages: Option<Vec<ArcMonitorChatMessage>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
input: Option<Vec<ResponseItem>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
policies: Option<ArcMonitorPolicies>,
|
||||
action: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ArcMonitorResult {
|
||||
outcome: ArcMonitorResultOutcome,
|
||||
short_reason: String,
|
||||
rationale: String,
|
||||
risk_score: u8,
|
||||
risk_level: ArcMonitorRiskLevel,
|
||||
evidence: Vec<ArcMonitorEvidence>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
struct ArcMonitorChatMessage {
|
||||
role: String,
|
||||
content: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
struct ArcMonitorPolicies {
|
||||
user: Option<String>,
|
||||
developer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ArcMonitorMetadata {
|
||||
codex_thread_id: String,
|
||||
codex_turn_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
protection_client_callsite: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[allow(dead_code)]
|
||||
struct ArcMonitorEvidence {
|
||||
message: String,
|
||||
why: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum ArcMonitorResultOutcome {
|
||||
Ok,
|
||||
SteerModel,
|
||||
AskUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum ArcMonitorRiskLevel {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
pub(crate) async fn monitor_action(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
action: serde_json::Value,
|
||||
protection_client_callsite: &'static str,
|
||||
) -> ArcMonitorOutcome {
|
||||
let auth = match turn_context.auth_manager.as_ref() {
|
||||
Some(auth_manager) => match auth_manager.auth().await {
|
||||
Some(auth) if auth.uses_codex_backend() => Some(auth),
|
||||
_ => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let env_token = read_non_empty_env_var(CODEX_ARC_MONITOR_TOKEN);
|
||||
if env_token.is_none() && auth.is_none() {
|
||||
return ArcMonitorOutcome::Ok;
|
||||
}
|
||||
|
||||
let url = read_non_empty_env_var(CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE).unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}/codex/safety/arc",
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/')
|
||||
)
|
||||
});
|
||||
let action = match action {
|
||||
serde_json::Value::Object(action) => action,
|
||||
_ => {
|
||||
warn!("skipping safety monitor because action payload is not an object");
|
||||
return ArcMonitorOutcome::Ok;
|
||||
}
|
||||
};
|
||||
let body =
|
||||
build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await;
|
||||
let client = build_reqwest_client();
|
||||
let mut request = client.post(&url).timeout(ARC_MONITOR_TIMEOUT).json(&body);
|
||||
if let Some(token) = env_token {
|
||||
request = request.bearer_auth(token);
|
||||
} else if let Some(auth) = auth.as_ref() {
|
||||
request =
|
||||
request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());
|
||||
}
|
||||
|
||||
let response = match request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
warn!(error = %err, %url, "safety monitor request failed");
|
||||
return ArcMonitorOutcome::Ok;
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let response_text = response.text().await.unwrap_or_default();
|
||||
warn!(
|
||||
%status,
|
||||
%url,
|
||||
response_text,
|
||||
"safety monitor returned non-success status"
|
||||
);
|
||||
return ArcMonitorOutcome::Ok;
|
||||
}
|
||||
|
||||
let response = match response.json::<ArcMonitorResult>().await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
warn!(error = %err, %url, "failed to parse safety monitor response");
|
||||
return ArcMonitorOutcome::Ok;
|
||||
}
|
||||
};
|
||||
tracing::debug!(
|
||||
risk_score = response.risk_score,
|
||||
risk_level = ?response.risk_level,
|
||||
evidence_count = response.evidence.len(),
|
||||
"safety monitor completed"
|
||||
);
|
||||
|
||||
let short_reason = response.short_reason.trim();
|
||||
let rationale = response.rationale.trim();
|
||||
match response.outcome {
|
||||
ArcMonitorResultOutcome::Ok => ArcMonitorOutcome::Ok,
|
||||
ArcMonitorResultOutcome::AskUser => {
|
||||
if !short_reason.is_empty() {
|
||||
ArcMonitorOutcome::AskUser(short_reason.to_string())
|
||||
} else if !rationale.is_empty() {
|
||||
ArcMonitorOutcome::AskUser(rationale.to_string())
|
||||
} else {
|
||||
ArcMonitorOutcome::AskUser(
|
||||
"Additional confirmation is required before this tool call can continue."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ArcMonitorResultOutcome::SteerModel => {
|
||||
if !rationale.is_empty() {
|
||||
ArcMonitorOutcome::SteerModel(rationale.to_string())
|
||||
} else if !short_reason.is_empty() {
|
||||
ArcMonitorOutcome::SteerModel(short_reason.to_string())
|
||||
} else {
|
||||
ArcMonitorOutcome::SteerModel(
|
||||
"Tool call was cancelled because of safety risks.".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_non_empty_env_var(key: &str) -> Option<String> {
|
||||
match env::var(key) {
|
||||
Ok(value) => {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
Err(env::VarError::NotPresent) => None,
|
||||
Err(env::VarError::NotUnicode(_)) => {
|
||||
warn!(
|
||||
env_var = key,
|
||||
"ignoring non-unicode safety monitor env override"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_arc_monitor_request(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
action: serde_json::Map<String, serde_json::Value>,
|
||||
protection_client_callsite: &'static str,
|
||||
) -> ArcMonitorRequest {
|
||||
let history = sess.clone_history().await;
|
||||
let mut messages = build_arc_monitor_messages(history.raw_items());
|
||||
if messages.is_empty() {
|
||||
messages.push(build_arc_monitor_message(
|
||||
"user",
|
||||
serde_json::Value::String(
|
||||
"No prior conversation history is available for this ARC evaluation.".to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let conversation_id = sess.conversation_id.to_string();
|
||||
ArcMonitorRequest {
|
||||
metadata: ArcMonitorMetadata {
|
||||
codex_thread_id: conversation_id.clone(),
|
||||
codex_turn_id: turn_context.sub_id.clone(),
|
||||
conversation_id: Some(conversation_id),
|
||||
protection_client_callsite: Some(protection_client_callsite.to_string()),
|
||||
},
|
||||
messages: Some(messages),
|
||||
input: None,
|
||||
policies: Some(ArcMonitorPolicies {
|
||||
user: None,
|
||||
developer: None,
|
||||
}),
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_arc_monitor_messages(items: &[ResponseItem]) -> Vec<ArcMonitorChatMessage> {
|
||||
let last_tool_call_index = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, item)| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
)
|
||||
})
|
||||
.map(|(index, _)| index);
|
||||
let last_encrypted_reasoning_index = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, item)| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Reasoning {
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} if !encrypted_content.trim().is_empty()
|
||||
)
|
||||
})
|
||||
.map(|(index, _)| index);
|
||||
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| {
|
||||
build_arc_monitor_message_item(
|
||||
item,
|
||||
index,
|
||||
last_tool_call_index,
|
||||
last_encrypted_reasoning_index,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_arc_monitor_message_item(
|
||||
item: &ResponseItem,
|
||||
index: usize,
|
||||
last_tool_call_index: Option<usize>,
|
||||
last_encrypted_reasoning_index: Option<usize>,
|
||||
) -> Option<ArcMonitorChatMessage> {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
if is_contextual_user_message_content(content) {
|
||||
None
|
||||
} else {
|
||||
content_items_to_text(content)
|
||||
.map(|text| build_arc_monitor_text_message("user", "input_text", text))
|
||||
}
|
||||
}
|
||||
ResponseItem::Message {
|
||||
role,
|
||||
content,
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
..
|
||||
} if role == "assistant" => content_items_to_text(content)
|
||||
.map(|text| build_arc_monitor_text_message("assistant", "output_text", text)),
|
||||
ResponseItem::Message { .. } => None,
|
||||
ResponseItem::Reasoning {
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} if Some(index) == last_encrypted_reasoning_index
|
||||
&& !encrypted_content.trim().is_empty() =>
|
||||
{
|
||||
Some(build_arc_monitor_message(
|
||||
"assistant",
|
||||
serde_json::json!([{
|
||||
"type": "encrypted_reasoning",
|
||||
"encrypted_content": encrypted_content,
|
||||
}]),
|
||||
))
|
||||
}
|
||||
ResponseItem::Reasoning { .. } => None,
|
||||
ResponseItem::LocalShellCall { action, .. } if Some(index) == last_tool_call_index => {
|
||||
Some(build_arc_monitor_message(
|
||||
"assistant",
|
||||
serde_json::json!([{
|
||||
"type": "tool_call",
|
||||
"tool_name": "shell",
|
||||
"action": action,
|
||||
}]),
|
||||
))
|
||||
}
|
||||
ResponseItem::FunctionCall {
|
||||
name, arguments, ..
|
||||
} if Some(index) == last_tool_call_index => Some(build_arc_monitor_message(
|
||||
"assistant",
|
||||
serde_json::json!([{
|
||||
"type": "tool_call",
|
||||
"tool_name": name,
|
||||
"arguments": arguments,
|
||||
}]),
|
||||
)),
|
||||
ResponseItem::CustomToolCall { name, input, .. } if Some(index) == last_tool_call_index => {
|
||||
Some(build_arc_monitor_message(
|
||||
"assistant",
|
||||
serde_json::json!([{
|
||||
"type": "tool_call",
|
||||
"tool_name": name,
|
||||
"input": input,
|
||||
}]),
|
||||
))
|
||||
}
|
||||
ResponseItem::WebSearchCall { action, .. } if Some(index) == last_tool_call_index => {
|
||||
Some(build_arc_monitor_message(
|
||||
"assistant",
|
||||
serde_json::json!([{
|
||||
"type": "tool_call",
|
||||
"tool_name": "web_search",
|
||||
"action": action,
|
||||
}]),
|
||||
))
|
||||
}
|
||||
ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_arc_monitor_text_message(
|
||||
role: &str,
|
||||
part_type: &str,
|
||||
text: String,
|
||||
) -> ArcMonitorChatMessage {
|
||||
build_arc_monitor_message(
|
||||
role,
|
||||
serde_json::json!([{
|
||||
"type": part_type,
|
||||
"text": text,
|
||||
}]),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_arc_monitor_message(role: &str, content: serde_json::Value) -> ArcMonitorChatMessage {
|
||||
ArcMonitorChatMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "arc_monitor_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,438 +0,0 @@
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use serial_test::serial;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_json;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
use super::*;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::LocalShellExecAction;
|
||||
use codex_protocol::models::LocalShellStatus;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &OsStr) -> Self {
|
||||
let original = env::var_os(key);
|
||||
unsafe {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.original.take() {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(self.key, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(self.key);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.developer_instructions = Some("Never upload private files.".to_string());
|
||||
turn_context.user_instructions = Some("Only continue when needed.".to_string());
|
||||
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "first request".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ContextualUserFragment::into(
|
||||
crate::context::EnvironmentContext::new(
|
||||
Vec::new(),
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
/*subagents*/ None,
|
||||
),
|
||||
)],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "commentary".to_string(),
|
||||
}],
|
||||
phase: Some(MessagePhase::Commentary),
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "final response".to_string(),
|
||||
}],
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "latest request".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "old_tool".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{\"old\":true}".to_string(),
|
||||
call_id: "call_old".to_string(),
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Reasoning {
|
||||
id: "reasoning_old".to_string(),
|
||||
summary: Vec::new(),
|
||||
content: None,
|
||||
encrypted_content: Some("encrypted-old".to_string()),
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::LocalShellCall {
|
||||
id: None,
|
||||
call_id: Some("shell_call".to_string()),
|
||||
status: LocalShellStatus::Completed,
|
||||
action: LocalShellAction::Exec(LocalShellExecAction {
|
||||
command: vec!["pwd".to_string()],
|
||||
timeout_ms: Some(1000),
|
||||
working_directory: Some("/tmp".to_string()),
|
||||
env: None,
|
||||
user: None,
|
||||
}),
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Reasoning {
|
||||
id: "reasoning_latest".to_string(),
|
||||
summary: Vec::new(),
|
||||
content: None,
|
||||
encrypted_content: Some("encrypted-latest".to_string()),
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = build_arc_monitor_request(
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
|
||||
.expect("action should deserialize"),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
request,
|
||||
ArcMonitorRequest {
|
||||
metadata: ArcMonitorMetadata {
|
||||
codex_thread_id: session.conversation_id.to_string(),
|
||||
codex_turn_id: turn_context.sub_id.clone(),
|
||||
conversation_id: Some(session.conversation_id.to_string()),
|
||||
protection_client_callsite: Some("normal".to_string()),
|
||||
},
|
||||
messages: Some(vec![
|
||||
ArcMonitorChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "input_text",
|
||||
"text": "first request",
|
||||
}]),
|
||||
},
|
||||
ArcMonitorChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "output_text",
|
||||
"text": "final response",
|
||||
}]),
|
||||
},
|
||||
ArcMonitorChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "input_text",
|
||||
"text": "latest request",
|
||||
}]),
|
||||
},
|
||||
ArcMonitorChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "tool_call",
|
||||
"tool_name": "shell",
|
||||
"action": {
|
||||
"type": "exec",
|
||||
"command": ["pwd"],
|
||||
"timeout_ms": 1000,
|
||||
"working_directory": "/tmp",
|
||||
"env": null,
|
||||
"user": null,
|
||||
},
|
||||
}]),
|
||||
},
|
||||
ArcMonitorChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "encrypted_reasoning",
|
||||
"encrypted_content": "encrypted-latest",
|
||||
}]),
|
||||
},
|
||||
]),
|
||||
input: None,
|
||||
policies: Some(ArcMonitorPolicies {
|
||||
user: None,
|
||||
developer: None,
|
||||
}),
|
||||
action: serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
|
||||
.expect("action should deserialize"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(arc_monitor_env)]
|
||||
async fn monitor_action_posts_expected_arc_request() {
|
||||
let server = MockServer::start().await;
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
turn_context.developer_instructions = Some("Developer policy".to_string());
|
||||
turn_context.user_instructions = Some("User policy".to_string());
|
||||
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "please run the tool".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.and(body_json(serde_json::json!({
|
||||
"metadata": {
|
||||
"codex_thread_id": session.conversation_id.to_string(),
|
||||
"codex_turn_id": turn_context.sub_id.clone(),
|
||||
"conversation_id": session.conversation_id.to_string(),
|
||||
"protection_client_callsite": "normal",
|
||||
},
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "please run the tool",
|
||||
}],
|
||||
}],
|
||||
"policies": {
|
||||
"developer": null,
|
||||
"user": null,
|
||||
},
|
||||
"action": {
|
||||
"tool": "mcp_tool_call",
|
||||
},
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "ask-user",
|
||||
"short_reason": "needs confirmation",
|
||||
"rationale": "tool call needs additional review",
|
||||
"risk_score": 42,
|
||||
"risk_level": "medium",
|
||||
"evidence": [{
|
||||
"message": "browser_navigate",
|
||||
"why": "tool call needs additional review",
|
||||
}],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let outcome = monitor_action(
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::json!({ "tool": "mcp_tool_call" }),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
ArcMonitorOutcome::AskUser("needs confirmation".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(arc_monitor_env)]
|
||||
async fn monitor_action_uses_env_url_and_token_overrides() {
|
||||
let server = MockServer::start().await;
|
||||
let _url_guard = EnvVarGuard::set(
|
||||
CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE,
|
||||
OsStr::new(&format!("{}/override/arc", server.uri())),
|
||||
);
|
||||
let _token_guard = EnvVarGuard::set(CODEX_ARC_MONITOR_TOKEN, OsStr::new("override-token"));
|
||||
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "please run the tool".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/override/arc"))
|
||||
.and(header("authorization", "Bearer override-token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "browser_navigate",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let outcome = monitor_action(
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::json!({ "tool": "mcp_tool_call" }),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
ArcMonitorOutcome::SteerModel("high-risk action".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(arc_monitor_env)]
|
||||
async fn monitor_action_rejects_legacy_response_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"reason": "legacy high-risk action",
|
||||
"monitorRequestId": "arc_456",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "please run the tool".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcome = monitor_action(
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::json!({ "tool": "mcp_tool_call" }),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, ArcMonitorOutcome::Ok);
|
||||
}
|
||||
+39
-16
@@ -146,6 +146,9 @@ const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str =
|
||||
const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
|
||||
const RESPONSES_ENDPOINT: &str = "/responses";
|
||||
const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact";
|
||||
// `/responses/compact` is unary, so the timeout covers the full response rather than one idle
|
||||
// period between stream events.
|
||||
const COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER: u32 = 4;
|
||||
const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize";
|
||||
#[cfg(test)]
|
||||
pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
|
||||
@@ -257,6 +260,7 @@ struct WebsocketSession {
|
||||
connection: Option<ApiWebSocketConnection>,
|
||||
last_request: Option<ResponsesApiRequest>,
|
||||
last_response_rx: Option<oneshot::Receiver<LastResponse>>,
|
||||
last_response_from_untraced_warmup: bool,
|
||||
connection_reused: StdMutex<bool>,
|
||||
}
|
||||
|
||||
@@ -502,12 +506,16 @@ impl ModelClient {
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
let compact_request_timeout = client_setup
|
||||
.api_provider
|
||||
.stream_idle_timeout
|
||||
.saturating_mul(COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER);
|
||||
let client =
|
||||
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
let trace_attempt = compaction_trace.start_attempt(&payload);
|
||||
let result = client
|
||||
.compact_input(&payload, extra_headers)
|
||||
.compact_input(&payload, extra_headers, compact_request_timeout)
|
||||
.await
|
||||
.map_err(map_api_error);
|
||||
trace_attempt.record_result(result.as_deref());
|
||||
@@ -741,8 +749,7 @@ impl ModelClient {
|
||||
prompt.output_schema_strict,
|
||||
);
|
||||
let prompt_cache_key = Some(self.state.thread_id.to_string());
|
||||
let service_tier =
|
||||
service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier));
|
||||
let service_tier = model_info.service_tier_for_request(service_tier);
|
||||
let request = ResponsesApiRequest {
|
||||
model: model_info.slug.clone(),
|
||||
instructions: instructions.clone(),
|
||||
@@ -931,10 +938,11 @@ impl Drop for ModelClientSession {
|
||||
}
|
||||
|
||||
impl ModelClientSession {
|
||||
pub(crate) fn reset_websocket_session(&mut self) {
|
||||
fn reset_websocket_session(&mut self) {
|
||||
self.websocket_session.connection = None;
|
||||
self.websocket_session.last_request = None;
|
||||
self.websocket_session.last_response_rx = None;
|
||||
self.websocket_session.last_response_from_untraced_warmup = false;
|
||||
self.websocket_session
|
||||
.set_connection_reused(/*connection_reused*/ false);
|
||||
}
|
||||
@@ -1038,28 +1046,33 @@ impl ModelClientSession {
|
||||
&mut self,
|
||||
payload: ResponseCreateWsRequest,
|
||||
request: &ResponsesApiRequest,
|
||||
) -> ResponsesWsRequest {
|
||||
) -> (ResponsesWsRequest, bool) {
|
||||
let Some(last_response) = self.get_last_response() else {
|
||||
return ResponsesWsRequest::ResponseCreate(payload);
|
||||
return (ResponsesWsRequest::ResponseCreate(payload), false);
|
||||
};
|
||||
let previous_response_id_from_untraced_warmup =
|
||||
self.websocket_session.last_response_from_untraced_warmup;
|
||||
let Some(incremental_items) = self.get_incremental_items(
|
||||
request,
|
||||
Some(&last_response),
|
||||
/*allow_empty_delta*/ true,
|
||||
) else {
|
||||
return ResponsesWsRequest::ResponseCreate(payload);
|
||||
return (ResponsesWsRequest::ResponseCreate(payload), false);
|
||||
};
|
||||
|
||||
if last_response.response_id.is_empty() {
|
||||
trace!("incremental request failed, no previous response id");
|
||||
return ResponsesWsRequest::ResponseCreate(payload);
|
||||
return (ResponsesWsRequest::ResponseCreate(payload), false);
|
||||
}
|
||||
|
||||
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
|
||||
previous_response_id: Some(last_response.response_id),
|
||||
input: incremental_items,
|
||||
..payload
|
||||
})
|
||||
(
|
||||
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
|
||||
previous_response_id: Some(last_response.response_id),
|
||||
input: incremental_items,
|
||||
..payload
|
||||
}),
|
||||
previous_response_id_from_untraced_warmup,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opportunistically preconnects a websocket for this turn-scoped client session.
|
||||
@@ -1138,6 +1151,7 @@ impl ModelClientSession {
|
||||
if needs_new {
|
||||
self.websocket_session.last_request = None;
|
||||
self.websocket_session.last_response_rx = None;
|
||||
self.websocket_session.last_response_from_untraced_warmup = false;
|
||||
let turn_state = options
|
||||
.turn_state
|
||||
.clone()
|
||||
@@ -1406,8 +1420,8 @@ impl ModelClientSession {
|
||||
Err(err) => return Err(map_api_error(err)),
|
||||
}
|
||||
|
||||
let mut ws_request = self.prepare_websocket_request(ws_payload, &request);
|
||||
self.websocket_session.last_request = Some(request);
|
||||
let (mut ws_request, previous_response_id_from_untraced_warmup) =
|
||||
self.prepare_websocket_request(ws_payload, &request);
|
||||
let inference_trace_attempt = if warmup {
|
||||
// Prewarm sends `generate=false`; it is connection setup, not a
|
||||
// model inference attempt that should appear in rollout traces.
|
||||
@@ -1416,7 +1430,16 @@ impl ModelClientSession {
|
||||
inference_trace.start_attempt()
|
||||
};
|
||||
stamp_ws_stream_request_start_ms(&mut ws_request);
|
||||
inference_trace_attempt.record_started(&ws_request);
|
||||
if previous_response_id_from_untraced_warmup {
|
||||
// The transport can reuse an untraced warmup response id and omit the
|
||||
// already-sent input, but rollout replay needs the logical model-visible
|
||||
// request rather than the compressed websocket delta.
|
||||
inference_trace_attempt.record_started(&request);
|
||||
} else {
|
||||
inference_trace_attempt.record_started(&ws_request);
|
||||
}
|
||||
self.websocket_session.last_request = Some(request);
|
||||
self.websocket_session.last_response_from_untraced_warmup = warmup;
|
||||
let websocket_connection =
|
||||
self.websocket_session.connection.as_ref().ok_or_else(|| {
|
||||
map_api_error(ApiError::Stream(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ pub struct ThreadConfigSnapshot {
|
||||
pub profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
pub ephemeral: bool,
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
pub reasoning_summary: Option<ReasoningSummary>,
|
||||
pub personality: Option<Personality>,
|
||||
pub collaboration_mode: CollaborationMode,
|
||||
pub session_source: SessionSource,
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
}
|
||||
@@ -80,9 +82,9 @@ impl ThreadConfigSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn context overrides that app-server validates before starting a turn.
|
||||
/// Thread settings overrides that app-server validates before starting a turn.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CodexThreadTurnContextOverrides {
|
||||
pub struct CodexThreadSettingsOverrides {
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
@@ -144,7 +146,7 @@ impl CodexThread {
|
||||
self.codex.session_loop_termination.clone().await;
|
||||
}
|
||||
|
||||
pub(crate) fn emit_thread_resume_lifecycle(&self) {
|
||||
pub(crate) async fn emit_thread_resume_lifecycle(&self) {
|
||||
for contributor in self
|
||||
.codex
|
||||
.session
|
||||
@@ -152,10 +154,12 @@ impl CodexThread {
|
||||
.extensions
|
||||
.thread_lifecycle_contributors()
|
||||
{
|
||||
contributor.on_thread_resume(codex_extension_api::ThreadResumeInput {
|
||||
session_store: &self.codex.session.services.session_extension_data,
|
||||
thread_store: &self.codex.session.services.thread_extension_data,
|
||||
});
|
||||
contributor
|
||||
.on_thread_resume(codex_extension_api::ThreadResumeInput {
|
||||
session_store: &self.codex.session.services.session_extension_data,
|
||||
thread_store: &self.codex.session.services.thread_extension_data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +244,17 @@ impl CodexThread {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Injects hidden model-visible items into the currently active turn.
|
||||
///
|
||||
/// This is the runtime-owned counterpart to user-facing `steer_input`.
|
||||
/// It returns the unchanged items when this thread has no active turn.
|
||||
pub async fn inject_response_items_into_active_turn(
|
||||
&self,
|
||||
items: Vec<ResponseInputItem>,
|
||||
) -> Result<(), Vec<ResponseInputItem>> {
|
||||
self.codex.session.inject_response_items(items).await
|
||||
}
|
||||
|
||||
pub async fn set_app_server_client_info(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
@@ -255,12 +270,20 @@ impl CodexThread {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Validate persistent turn context overrides without committing them.
|
||||
pub async fn validate_turn_context_overrides(
|
||||
/// Preview persistent thread settings overrides without committing them.
|
||||
pub async fn preview_thread_settings_overrides(
|
||||
&self,
|
||||
overrides: CodexThreadTurnContextOverrides,
|
||||
) -> ConstraintResult<()> {
|
||||
let CodexThreadTurnContextOverrides {
|
||||
overrides: CodexThreadSettingsOverrides,
|
||||
) -> ConstraintResult<ThreadConfigSnapshot> {
|
||||
let updates = self.thread_settings_update(overrides).await;
|
||||
self.codex.session.preview_settings(&updates).await
|
||||
}
|
||||
|
||||
async fn thread_settings_update(
|
||||
&self,
|
||||
overrides: CodexThreadSettingsOverrides,
|
||||
) -> SessionSettingsUpdate {
|
||||
let CodexThreadSettingsOverrides {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
@@ -287,7 +310,7 @@ impl CodexThread {
|
||||
.with_updates(model, effort, /*developer_instructions*/ None)
|
||||
};
|
||||
|
||||
let updates = SessionSettingsUpdate {
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
@@ -302,8 +325,7 @@ impl CodexThread {
|
||||
service_tier,
|
||||
personality,
|
||||
..Default::default()
|
||||
};
|
||||
self.codex.session.validate_settings(&updates).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Use sparingly: this is intended to be removed soon.
|
||||
@@ -381,6 +403,7 @@ impl CodexThread {
|
||||
{
|
||||
self.codex
|
||||
.session
|
||||
.input_queue
|
||||
.queue_response_items_for_next_turn(items)
|
||||
.await;
|
||||
self.codex.session.maybe_start_turn_for_pending_work().await;
|
||||
|
||||
@@ -100,6 +100,7 @@ pub(crate) async fn run_compact_task(
|
||||
) -> CodexResult<()> {
|
||||
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
|
||||
model_context_window: turn_context.model_context_window(),
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
@@ -282,7 +283,6 @@ async fn run_compact_task_inner_impl(
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
client_session.reset_websocket_session();
|
||||
sess.recompute_token_usage(&turn_context).await;
|
||||
|
||||
sess.emit_turn_item_completed(&turn_context, compaction_item)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Prompt;
|
||||
@@ -63,6 +62,7 @@ pub(crate) async fn run_remote_compact_task(
|
||||
) -> CodexResult<()> {
|
||||
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
|
||||
model_context_window: turn_context.model_context_window(),
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
@@ -174,9 +174,6 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&prompt_input,
|
||||
&HashSet::new(),
|
||||
/*skills_outcome*/ None,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
@@ -290,7 +287,7 @@ pub(crate) async fn process_compacted_history(
|
||||
/// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary
|
||||
/// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this
|
||||
/// check.
|
||||
fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, .. } if role == "developer" => false,
|
||||
ResponseItem::Message { role, .. } if role == "user" => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Prompt;
|
||||
@@ -11,7 +10,14 @@ use crate::compact::compaction_status_from_result;
|
||||
use crate::compact_remote::build_compact_request_log_data;
|
||||
use crate::compact_remote::log_remote_compact_failure;
|
||||
use crate::compact_remote::process_compacted_history;
|
||||
use crate::compact_remote::should_keep_compacted_history_item;
|
||||
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
|
||||
use crate::hook_runtime::PostCompactHookOutcome;
|
||||
use crate::hook_runtime::PreCompactHookOutcome;
|
||||
use crate::hook_runtime::run_post_compact_hooks;
|
||||
use crate::hook_runtime::run_pre_compact_hooks;
|
||||
use crate::responses_retry::ResponsesStreamRequest;
|
||||
use crate::responses_retry::handle_retryable_response_stream_error;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn::built_tools;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
@@ -24,17 +30,27 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::items::ContextCompactionItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::TruncationPolicy;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_rollout_trace::CompactionCheckpointTracePayload;
|
||||
use codex_rollout_trace::InferenceTraceContext;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use futures::StreamExt;
|
||||
use futures::TryFutureExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
// Mirror the current /responses/compact retained-message default while the
|
||||
// server-side path remains the reference implementation.
|
||||
const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000;
|
||||
// Compact attempts can run much longer than normal turns, so keep the per-transport
|
||||
// retry budget smaller than the general Responses stream retry budget.
|
||||
const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2;
|
||||
|
||||
pub(crate) async fn run_inline_remote_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
@@ -61,6 +77,7 @@ pub(crate) async fn run_remote_compact_task(
|
||||
) -> CodexResult<()> {
|
||||
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
|
||||
model_context_window: turn_context.model_context_window(),
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
@@ -93,10 +110,25 @@ async fn run_remote_compact_task_inner(
|
||||
turn_context.as_ref(),
|
||||
trigger,
|
||||
reason,
|
||||
CompactionImplementation::Responses,
|
||||
CompactionImplementation::ResponsesCompactionV2,
|
||||
phase,
|
||||
)
|
||||
.await;
|
||||
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
codex_analytics::CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
sess,
|
||||
turn_context,
|
||||
@@ -104,13 +136,16 @@ async fn run_remote_compact_task_inner(
|
||||
initial_context_injection,
|
||||
)
|
||||
.await;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
compaction_status_from_result(&result),
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
attempt.track(sess.as_ref(), status, error).await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
attempt.track(sess.as_ref(), status, error.clone()).await;
|
||||
if let Err(err) = result {
|
||||
let event = EventMsg::Error(
|
||||
err.to_error_event(Some("Error running remote compact task".to_string())),
|
||||
@@ -158,9 +193,6 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&prompt_input,
|
||||
&HashSet::new(),
|
||||
/*skills_outcome*/ None,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
@@ -250,31 +282,71 @@ async fn run_remote_compaction_request_v2(
|
||||
prompt: &Prompt,
|
||||
turn_metadata_header: Option<&str>,
|
||||
) -> CodexResult<(ResponseItem, String)> {
|
||||
let stream = client_session
|
||||
.stream(
|
||||
prompt,
|
||||
&turn_context.model_info,
|
||||
&turn_context.session_telemetry,
|
||||
turn_context.reasoning_effort,
|
||||
turn_context.reasoning_summary,
|
||||
turn_context.config.service_tier.clone(),
|
||||
turn_metadata_header,
|
||||
&InferenceTraceContext::disabled(),
|
||||
)
|
||||
.or_else(|err| async {
|
||||
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
|
||||
let compact_request_log_data =
|
||||
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
|
||||
log_remote_compact_failure(
|
||||
turn_context,
|
||||
&compact_request_log_data,
|
||||
total_usage_breakdown,
|
||||
&err,
|
||||
);
|
||||
Err(err)
|
||||
})
|
||||
.await?;
|
||||
collect_compaction_output(stream).await
|
||||
let max_retries = turn_context
|
||||
.provider
|
||||
.info()
|
||||
.stream_max_retries()
|
||||
.min(MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES);
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
let result = match client_session
|
||||
.stream(
|
||||
prompt,
|
||||
&turn_context.model_info,
|
||||
&turn_context.session_telemetry,
|
||||
turn_context.reasoning_effort,
|
||||
turn_context.reasoning_summary,
|
||||
turn_context.config.service_tier.clone(),
|
||||
turn_metadata_header,
|
||||
&InferenceTraceContext::disabled(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => collect_compaction_output(stream).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(compaction_output) => return Ok(compaction_output),
|
||||
Err(err) if !err.is_retryable() => {
|
||||
log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await;
|
||||
return Err(err);
|
||||
}
|
||||
Err(err) => {
|
||||
if let Err(err) = handle_retryable_response_stream_error(
|
||||
&mut retries,
|
||||
max_retries,
|
||||
err,
|
||||
client_session,
|
||||
sess,
|
||||
turn_context,
|
||||
ResponsesStreamRequest::RemoteCompactionV2,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_remote_compaction_request_failure(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
prompt: &Prompt,
|
||||
err: &CodexErr,
|
||||
) {
|
||||
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
|
||||
let compact_request_log_data =
|
||||
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
|
||||
log_remote_compact_failure(
|
||||
turn_context,
|
||||
&compact_request_log_data,
|
||||
total_usage_breakdown,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
async fn collect_compaction_output(
|
||||
@@ -304,8 +376,9 @@ async fn collect_compaction_output(
|
||||
}
|
||||
|
||||
let Some(response_id) = completed_response_id else {
|
||||
return Err(CodexErr::Fatal(
|
||||
return Err(CodexErr::Stream(
|
||||
"remote compaction v2 stream closed before response.completed".to_string(),
|
||||
None,
|
||||
));
|
||||
};
|
||||
|
||||
@@ -325,11 +398,14 @@ fn build_v2_compacted_history(
|
||||
prompt_input: &[ResponseItem],
|
||||
compaction_output: ResponseItem,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut retained = prompt_input
|
||||
let retained = prompt_input
|
||||
.iter()
|
||||
.filter(|item| is_retained_for_remote_compaction_v2(item))
|
||||
.filter(|item| should_keep_compacted_history_item(item))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut retained =
|
||||
truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET);
|
||||
retained.push(compaction_output);
|
||||
retained
|
||||
}
|
||||
@@ -342,6 +418,98 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool {
|
||||
matches!(role.as_str(), "user" | "developer" | "system")
|
||||
}
|
||||
|
||||
fn truncate_retained_messages_for_remote_compaction(
|
||||
items: Vec<ResponseItem>,
|
||||
max_tokens: usize,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut remaining = max_tokens;
|
||||
let mut truncated_reversed = Vec::with_capacity(items.len());
|
||||
for item in items.into_iter().rev() {
|
||||
if remaining == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let token_count = message_text_token_count(&item).max(1);
|
||||
if token_count <= remaining {
|
||||
truncated_reversed.push(item);
|
||||
remaining = remaining.saturating_sub(token_count);
|
||||
} else if let Some(truncated_item) =
|
||||
truncate_message_text_to_token_budget(item, /*max_tokens*/ remaining)
|
||||
{
|
||||
truncated_reversed.push(truncated_item);
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
truncated_reversed.reverse();
|
||||
truncated_reversed
|
||||
}
|
||||
|
||||
fn message_text_token_count(item: &ResponseItem) -> usize {
|
||||
let ResponseItem::Message { content, .. } = item else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
content
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
approx_token_count(text)
|
||||
}
|
||||
ContentItem::InputImage { .. } => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn truncate_message_text_to_token_budget(
|
||||
item: ResponseItem,
|
||||
max_tokens: usize,
|
||||
) -> Option<ResponseItem> {
|
||||
let ResponseItem::Message {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
phase,
|
||||
} = item
|
||||
else {
|
||||
return Some(item);
|
||||
};
|
||||
|
||||
let mut remaining = max_tokens;
|
||||
let mut truncated_content = Vec::with_capacity(content.len());
|
||||
for mut content_item in content {
|
||||
match &mut content_item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
if remaining == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let token_count = approx_token_count(text);
|
||||
if token_count <= remaining {
|
||||
remaining = remaining.saturating_sub(token_count);
|
||||
} else {
|
||||
*text = truncate_text(text, TruncationPolicy::Tokens(remaining));
|
||||
remaining = 0;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
truncated_content.push(content_item);
|
||||
}
|
||||
}
|
||||
ContentItem::InputImage { .. } => truncated_content.push(content_item),
|
||||
}
|
||||
}
|
||||
|
||||
if truncated_content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ResponseItem::Message {
|
||||
id,
|
||||
role,
|
||||
content: truncated_content,
|
||||
phase,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -377,7 +545,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_v2_compacted_history_matches_prod_retention_shape() {
|
||||
fn build_v2_compacted_history_filters_to_installed_retention_shape() {
|
||||
let input = vec![
|
||||
message("developer", "dev", /*phase*/ None),
|
||||
message("system", "sys", /*phase*/ None),
|
||||
@@ -403,15 +571,145 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
history,
|
||||
vec![message("user", "user", /*phase*/ None), output]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_v2_compacted_history_discards_messages_before_truncating() {
|
||||
let old = message("user", "old", /*phase*/ None);
|
||||
let new = message("user", "new", /*phase*/ None);
|
||||
let huge_developer_message = "d".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4);
|
||||
let huge_contextual_message = format!(
|
||||
"<environment_context>\n{}\n</environment_context>",
|
||||
"c".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4)
|
||||
);
|
||||
let input = vec![
|
||||
old.clone(),
|
||||
message("developer", &huge_developer_message, /*phase*/ None),
|
||||
message("user", &huge_contextual_message, /*phase*/ None),
|
||||
new.clone(),
|
||||
];
|
||||
let output = ResponseItem::Compaction {
|
||||
encrypted_content: "new".to_string(),
|
||||
};
|
||||
|
||||
let history = build_v2_compacted_history(&input, output.clone());
|
||||
|
||||
assert_eq!(history, vec![old, new, output]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_history_truncation_keeps_newest_messages_first() {
|
||||
let middle = message("user", "middle1234", /*phase*/ None);
|
||||
let new = message("user", "new", /*phase*/ None);
|
||||
let retained = vec![
|
||||
message("user", "old-old", /*phase*/ None),
|
||||
middle,
|
||||
new.clone(),
|
||||
];
|
||||
|
||||
let truncated =
|
||||
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 3);
|
||||
|
||||
assert_eq!(
|
||||
truncated,
|
||||
vec![
|
||||
message("developer", "dev", /*phase*/ None),
|
||||
message("system", "sys", /*phase*/ None),
|
||||
message("user", "user", /*phase*/ None),
|
||||
output,
|
||||
message("user", "midd…1 tokens truncated…1234", /*phase*/ None),
|
||||
new,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_history_truncation_preserves_images_and_truncates_later_text_parts() {
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: "abcdef".to_string(),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abc".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
ContentItem::OutputText {
|
||||
text: "uvwxyz".to_string(),
|
||||
},
|
||||
],
|
||||
phase: None,
|
||||
};
|
||||
|
||||
let truncated =
|
||||
truncate_retained_messages_for_remote_compaction(vec![item], /*max_tokens*/ 3);
|
||||
|
||||
assert_eq!(
|
||||
truncated,
|
||||
vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: "abcdef".to_string(),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abc".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
ContentItem::OutputText {
|
||||
text: "uv…1 tokens truncated…yz".to_string(),
|
||||
},
|
||||
],
|
||||
phase: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_history_truncation_charges_image_only_messages() {
|
||||
let image_only_message = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abc".to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
phase: None,
|
||||
};
|
||||
let newest = message("user", "new", /*phase*/ None);
|
||||
let retained = vec![
|
||||
message("user", "old", /*phase*/ None),
|
||||
image_only_message.clone(),
|
||||
newest.clone(),
|
||||
];
|
||||
|
||||
let truncated =
|
||||
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 2);
|
||||
|
||||
assert_eq!(truncated, vec![image_only_message, newest]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_history_truncation_drops_image_only_messages_after_budget_is_spent() {
|
||||
let image_only_message = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abc".to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
phase: None,
|
||||
};
|
||||
let newest = message("user", "new", /*phase*/ None);
|
||||
let retained = vec![image_only_message, newest.clone()];
|
||||
|
||||
let truncated =
|
||||
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 1);
|
||||
|
||||
assert_eq!(truncated, vec![newest]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_compaction_output_accepts_additional_output_items() {
|
||||
let compaction = ResponseItem::Compaction {
|
||||
|
||||
@@ -27,9 +27,10 @@ use codex_config::loader::load_requirements_toml;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -821,6 +822,7 @@ flag = false
|
||||
async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()>
|
||||
{
|
||||
use base64::Engine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Ok(());
|
||||
@@ -913,14 +915,7 @@ allowed_sandbox_modes = ["read-only"]
|
||||
state
|
||||
.requirements()
|
||||
.permission_profile
|
||||
.can_set(&PermissionProfile::from_legacy_sandbox_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
},
|
||||
))
|
||||
.can_set(&PermissionProfile::workspace_write())
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -1102,9 +1097,12 @@ allowed_approval_policies = ["on-request"]
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_permissions: None,
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
allow_appshots: None,
|
||||
computer_use: None,
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
@@ -1160,9 +1158,12 @@ allowed_approval_policies = ["on-request"]
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_permissions: None,
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
allow_appshots: None,
|
||||
computer_use: None,
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
@@ -1233,11 +1234,9 @@ allowed_sandbox_modes = ["read-only"]
|
||||
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
config_requirements.permission_profile.can_set(
|
||||
&PermissionProfile::from_legacy_sandbox_policy(
|
||||
&SandboxPolicy::new_workspace_write_policy()
|
||||
)
|
||||
),
|
||||
config_requirements
|
||||
.permission_profile
|
||||
.can_set(&PermissionProfile::workspace_write()),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "WorkspaceWrite".into(),
|
||||
@@ -1369,9 +1368,12 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_permissions: None,
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
allow_appshots: None,
|
||||
computer_use: None,
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
@@ -1417,6 +1419,264 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_requirements_define_managed_permission_profiles() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
default_permissions = "managed-standard"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
let requirements_path = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_path,
|
||||
r#"
|
||||
allowed_permissions = ["managed-standard"]
|
||||
|
||||
[permissions.managed-standard]
|
||||
extends = ":workspace"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
overrides.system_requirements_path = Some(requirements_path);
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.loader_overrides(overrides)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.config_layer_stack
|
||||
.requirements_toml()
|
||||
.allowed_permissions,
|
||||
Some(vec!["managed-standard".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.permissions
|
||||
.active_permission_profile()
|
||||
.map(|profile| profile.id),
|
||||
Some("managed-standard".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> {
|
||||
for (trust_level, expected_profile) in [
|
||||
(
|
||||
Some(TrustLevel::Trusted),
|
||||
if cfg!(target_os = "windows") {
|
||||
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
|
||||
} else {
|
||||
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
||||
},
|
||||
),
|
||||
(
|
||||
Some(TrustLevel::Untrusted),
|
||||
if cfg!(target_os = "windows") {
|
||||
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
|
||||
} else {
|
||||
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
||||
},
|
||||
),
|
||||
(None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY),
|
||||
] {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
if let Some(trust_level) = trust_level {
|
||||
make_config_for_test(
|
||||
&codex_home,
|
||||
tmp.path(),
|
||||
trust_level,
|
||||
/*project_root_markers*/ None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let requirements_path = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_path,
|
||||
r#"
|
||||
allowed_permissions = ["managed-standard"]
|
||||
|
||||
[permissions.managed-standard.filesystem]
|
||||
":workspace_roots" = "read"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
overrides.system_requirements_path = Some(requirements_path);
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.loader_overrides(overrides)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.permissions
|
||||
.active_permission_profile()
|
||||
.map(|profile| profile.id),
|
||||
Some(expected_profile.to_string()),
|
||||
"trust level {trust_level:?}",
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
default_permissions = ":workspace"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
let requirements_path = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_path,
|
||||
r#"
|
||||
allowed_permissions = ["managed-standard"]
|
||||
|
||||
[permissions.managed-standard.filesystem]
|
||||
":workspace_roots" = "read"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
overrides.system_requirements_path = Some(requirements_path);
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.loader_overrides(overrides)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.permissions
|
||||
.active_permission_profile()
|
||||
.map(|profile| profile.id),
|
||||
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()>
|
||||
{
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
default_permissions = "managed-build"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
let requirements_path = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_path,
|
||||
r#"
|
||||
allowed_permissions = ["managed-standard", "managed-build"]
|
||||
|
||||
[permissions.managed-standard]
|
||||
extends = ":read-only"
|
||||
|
||||
[permissions.managed-build]
|
||||
extends = ":workspace"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
overrides.system_requirements_path = Some(requirements_path);
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.loader_overrides(overrides)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.permissions
|
||||
.active_permission_profile()
|
||||
.map(|profile| profile.id),
|
||||
Some("managed-build".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_requirements_warn_for_disallowed_explicit_permission_override() -> anyhow::Result<()>
|
||||
{
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
let requirements_path = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_path,
|
||||
r#"
|
||||
allowed_permissions = ["managed-standard"]
|
||||
|
||||
[permissions.managed-standard]
|
||||
extends = ":workspace"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
overrides.system_requirements_path = Some(requirements_path);
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.harness_overrides(ConfigOverrides {
|
||||
default_permissions: Some("managed-build".to_string()),
|
||||
..ConfigOverrides::default()
|
||||
})
|
||||
.loader_overrides(overrides)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.permissions
|
||||
.active_permission_profile()
|
||||
.map(|profile| profile.id),
|
||||
Some("managed-standard".to_string())
|
||||
);
|
||||
assert!(
|
||||
config.startup_warnings.iter().any(|warning| warning
|
||||
.contains("Configured value for `permission_profile` is disallowed by requirements")),
|
||||
"{:?}",
|
||||
config.startup_warnings
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
@@ -1570,9 +1830,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::
|
||||
layers
|
||||
.requirements()
|
||||
.permission_profile
|
||||
.can_set(&PermissionProfile::from_legacy_sandbox_policy(
|
||||
&SandboxPolicy::new_workspace_write_policy()
|
||||
))
|
||||
.can_set(&PermissionProfile::workspace_write())
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
@@ -2353,6 +2611,7 @@ model = "project-model"
|
||||
model_instructions_file = "instructions.md"
|
||||
openai_base_url = "https://attacker.example/v1"
|
||||
chatgpt_base_url = "https://attacker.example/backend-api"
|
||||
apps_mcp_product_sku = "attacker"
|
||||
model_provider = "attacker"
|
||||
notify = ["sh", "-c", "echo attacker"]
|
||||
profile = "attacker"
|
||||
@@ -2404,6 +2663,7 @@ wire_api = "responses"
|
||||
let ignored_project_config_keys = vec![
|
||||
"openai_base_url",
|
||||
"chatgpt_base_url",
|
||||
"apps_mcp_product_sku",
|
||||
"model_provider",
|
||||
"model_providers",
|
||||
"notify",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,8 +40,6 @@ pub enum ConfigEdit {
|
||||
SetNoticeHideFullAccessWarning(bool),
|
||||
/// Toggle the Windows world-writable directories warning acknowledgement flag.
|
||||
SetNoticeHideWorldWritableWarning(bool),
|
||||
/// Toggle the opt-out marker for Codex-managed fast defaults.
|
||||
SetNoticeFastDefaultOptOut(bool),
|
||||
/// Toggle the rate limit model nudge acknowledgement flag.
|
||||
SetNoticeHideRateLimitModelNudge(bool),
|
||||
/// Toggle the model migration prompt acknowledgement flag.
|
||||
@@ -302,8 +300,8 @@ mod document_helpers {
|
||||
if !config.enabled {
|
||||
entry["enabled"] = value(false);
|
||||
}
|
||||
if let Some(environment) = &config.experimental_environment {
|
||||
entry["experimental_environment"] = value(environment.clone());
|
||||
if !config.is_local_environment() {
|
||||
entry["environment_id"] = value(config.environment_id.clone());
|
||||
}
|
||||
if config.required {
|
||||
entry["required"] = value(true);
|
||||
@@ -515,13 +513,6 @@ mod document_helpers {
|
||||
|
||||
struct ConfigDocument {
|
||||
doc: DocumentMut,
|
||||
profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum Scope {
|
||||
Global,
|
||||
Profile,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
@@ -531,27 +522,29 @@ enum TraversalMode {
|
||||
}
|
||||
|
||||
impl ConfigDocument {
|
||||
fn new(doc: DocumentMut, profile: Option<String>) -> Self {
|
||||
Self { doc, profile }
|
||||
fn new(doc: DocumentMut) -> Self {
|
||||
Self { doc }
|
||||
}
|
||||
|
||||
fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result<bool> {
|
||||
match edit {
|
||||
ConfigEdit::SetModel { model, effort } => Ok({
|
||||
let mut mutated = false;
|
||||
mutated |= self.write_profile_value(
|
||||
mutated |= self.write_optional_value(
|
||||
&["model"],
|
||||
model.as_ref().map(|model_value| value(model_value.clone())),
|
||||
);
|
||||
mutated |= self.write_profile_value(
|
||||
mutated |= self.write_optional_value(
|
||||
&["model_reasoning_effort"],
|
||||
effort.map(|effort| value(effort.to_string())),
|
||||
);
|
||||
mutated
|
||||
}),
|
||||
ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value(
|
||||
ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_optional_value(
|
||||
&["service_tier"],
|
||||
service_tier.as_ref().map(|service_tier| {
|
||||
// Keep the legacy config spelling stable. Runtime values use
|
||||
// `priority`, but config.toml continues to store it as `fast`.
|
||||
let config_value = match ServiceTier::from_request_value(service_tier) {
|
||||
Some(ServiceTier::Fast) => "fast",
|
||||
Some(ServiceTier::Flex) => "flex",
|
||||
@@ -560,40 +553,30 @@ impl ConfigDocument {
|
||||
value(config_value)
|
||||
}),
|
||||
)),
|
||||
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value(
|
||||
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_optional_value(
|
||||
&["personality"],
|
||||
personality.map(|personality| value(personality.to_string())),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[NOTICE_TABLE_KEY, "hide_full_access_warning"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[NOTICE_TABLE_KEY, "fast_default_opt_out"],
|
||||
value(*opted_out),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => {
|
||||
Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[NOTICE_TABLE_KEY, migration_config.as_str()],
|
||||
value(*acknowledged),
|
||||
))
|
||||
}
|
||||
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self
|
||||
.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
@@ -603,7 +586,6 @@ impl ConfigDocument {
|
||||
)),
|
||||
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => {
|
||||
Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
@@ -616,7 +598,6 @@ impl ConfigDocument {
|
||||
project,
|
||||
acknowledged,
|
||||
) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
@@ -629,7 +610,6 @@ impl ConfigDocument {
|
||||
project,
|
||||
timestamp,
|
||||
) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
@@ -639,7 +619,6 @@ impl ConfigDocument {
|
||||
value(*timestamp),
|
||||
)),
|
||||
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
|
||||
value(to.clone()),
|
||||
)),
|
||||
@@ -668,20 +647,26 @@ impl ConfigDocument {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_profile_value(&mut self, segments: &[&str], value: Option<TomlItem>) -> bool {
|
||||
fn write_optional_value(&mut self, segments: &[&str], value: Option<TomlItem>) -> bool {
|
||||
match value {
|
||||
Some(item) => self.write_value(Scope::Profile, segments, item),
|
||||
None => self.clear(Scope::Profile, segments),
|
||||
Some(item) => self.write_value(segments, item),
|
||||
None => self.clear(segments),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool {
|
||||
let resolved = self.scoped_segments(scope, segments);
|
||||
fn write_value(&mut self, segments: &[&str], value: TomlItem) -> bool {
|
||||
let resolved = segments
|
||||
.iter()
|
||||
.map(|segment| (*segment).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
self.insert(&resolved, value)
|
||||
}
|
||||
|
||||
fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool {
|
||||
let resolved = self.scoped_segments(scope, segments);
|
||||
fn clear(&mut self, segments: &[&str]) -> bool {
|
||||
let resolved = segments
|
||||
.iter()
|
||||
.map(|segment| (*segment).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
self.remove(&resolved)
|
||||
}
|
||||
|
||||
@@ -714,7 +699,6 @@ impl ConfigDocument {
|
||||
.filter(|disabled_tool| seen.insert(disabled_tool.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
self.write_value(
|
||||
Scope::Global,
|
||||
&["tool_suggest", "disabled_tools"],
|
||||
document_helpers::tool_suggest_disabled_tools_value(&disabled_tools),
|
||||
)
|
||||
@@ -726,7 +710,7 @@ impl ConfigDocument {
|
||||
|
||||
fn replace_mcp_servers(&mut self, servers: &BTreeMap<String, McpServerConfig>) -> bool {
|
||||
if servers.is_empty() {
|
||||
return self.clear(Scope::Global, &["mcp_servers"]);
|
||||
return self.clear(&["mcp_servers"]);
|
||||
}
|
||||
|
||||
let root = self.doc.as_table_mut();
|
||||
@@ -888,26 +872,6 @@ impl ConfigDocument {
|
||||
mutated
|
||||
}
|
||||
|
||||
fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec<String> {
|
||||
let resolved: Vec<String> = segments
|
||||
.iter()
|
||||
.map(|segment| (*segment).to_string())
|
||||
.collect();
|
||||
|
||||
if matches!(scope, Scope::Profile)
|
||||
&& resolved.first().is_none_or(|segment| segment != "profiles")
|
||||
&& let Some(profile) = self.profile.as_deref()
|
||||
{
|
||||
let mut scoped = Vec::with_capacity(resolved.len() + 2);
|
||||
scoped.push("profiles".to_string());
|
||||
scoped.push(profile.to_string());
|
||||
scoped.extend(resolved);
|
||||
return scoped;
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
fn insert(&mut self, segments: &[String], value: TomlItem) -> bool {
|
||||
let Some((last, parents)) = segments.split_last() else {
|
||||
return false;
|
||||
@@ -1035,18 +999,13 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele
|
||||
}
|
||||
|
||||
/// Persist edits using a blocking strategy.
|
||||
pub fn apply_blocking(
|
||||
codex_home: &Path,
|
||||
profile: Option<&str>,
|
||||
edits: &[ConfigEdit],
|
||||
) -> anyhow::Result<()> {
|
||||
pub fn apply_blocking(codex_home: &Path, edits: &[ConfigEdit]) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
apply_blocking_to_resolved_file(&config_path, profile, edits)
|
||||
apply_blocking_to_resolved_file(&config_path, edits)
|
||||
}
|
||||
|
||||
fn apply_blocking_to_resolved_file(
|
||||
resolved_config_file: &Path,
|
||||
legacy_profile: Option<&str>,
|
||||
edits: &[ConfigEdit],
|
||||
) -> anyhow::Result<()> {
|
||||
if edits.is_empty() {
|
||||
@@ -1069,13 +1028,7 @@ fn apply_blocking_to_resolved_file(
|
||||
serialized.parse::<DocumentMut>()?
|
||||
};
|
||||
|
||||
let profile = legacy_profile.map(ToOwned::to_owned).or_else(|| {
|
||||
doc.get("profile")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
|
||||
let mut document = ConfigDocument::new(doc, profile);
|
||||
let mut document = ConfigDocument::new(doc);
|
||||
let mut mutated = false;
|
||||
|
||||
for edit in edits {
|
||||
@@ -1098,29 +1051,18 @@ fn apply_blocking_to_resolved_file(
|
||||
|
||||
/// Persist edits asynchronously by offloading the blocking writer.
|
||||
///
|
||||
/// `profile` selects a legacy `[profiles.<name>]` section inside
|
||||
/// `$CODEX_HOME/config.toml`; profile-v2 callers should resolve their target
|
||||
/// file before constructing a [ConfigEditsBuilder].
|
||||
pub async fn apply(
|
||||
codex_home: &Path,
|
||||
profile: Option<&str>,
|
||||
edits: Vec<ConfigEdit>,
|
||||
) -> anyhow::Result<()> {
|
||||
pub async fn apply(codex_home: &Path, edits: Vec<ConfigEdit>) -> anyhow::Result<()> {
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let profile = profile.map(ToOwned::to_owned);
|
||||
task::spawn_blocking(move || {
|
||||
apply_blocking_to_resolved_file(&config_path, profile.as_deref(), &edits)
|
||||
})
|
||||
.await
|
||||
.context("config persistence task panicked")?
|
||||
task::spawn_blocking(move || apply_blocking_to_resolved_file(&config_path, &edits))
|
||||
.await
|
||||
.context("config persistence task panicked")?
|
||||
}
|
||||
|
||||
/// Fluent builder to batch config edits and apply them atomically.
|
||||
#[derive(Default)]
|
||||
pub struct ConfigEditsBuilder {
|
||||
config_path: PathBuf,
|
||||
profile: Option<String>,
|
||||
edits: Vec<ConfigEdit>,
|
||||
}
|
||||
|
||||
@@ -1141,16 +1083,10 @@ impl ConfigEditsBuilder {
|
||||
pub fn for_config_path(config_path: &Path) -> Self {
|
||||
Self {
|
||||
config_path: config_path.to_path_buf(),
|
||||
profile: None,
|
||||
edits: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_profile(mut self, profile: Option<&str>) -> Self {
|
||||
self.profile = profile.map(ToOwned::to_owned);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_model(mut self, model: Option<&str>, effort: Option<ReasoningEffort>) -> Self {
|
||||
self.edits.push(ConfigEdit::SetModel {
|
||||
model: model.map(ToOwned::to_owned),
|
||||
@@ -1182,12 +1118,6 @@ impl ConfigEditsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self {
|
||||
self.edits
|
||||
.push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
|
||||
self.edits
|
||||
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
|
||||
@@ -1259,27 +1189,16 @@ impl ConfigEditsBuilder {
|
||||
|
||||
/// Enable or disable a feature flag by key under the `[features]` table.
|
||||
///
|
||||
/// Disabling a default-false feature clears the root-scoped key instead of
|
||||
/// Disabling a default-false feature clears the key instead of
|
||||
/// persisting `false`, so the config does not pin the feature once it
|
||||
/// graduates to globally enabled. Profile-scoped disables still persist
|
||||
/// `false` so they can override an inherited root enable.
|
||||
/// graduates to globally enabled.
|
||||
pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self {
|
||||
let profile_scoped = self.profile.is_some();
|
||||
let segments = if let Some(profile) = self.profile.as_ref() {
|
||||
vec![
|
||||
"profiles".to_string(),
|
||||
profile.clone(),
|
||||
"features".to_string(),
|
||||
key.to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["features".to_string(), key.to_string()]
|
||||
};
|
||||
let segments = vec!["features".to_string(), key.to_string()];
|
||||
let is_default_false_feature = FEATURES
|
||||
.iter()
|
||||
.find(|spec| spec.key == key)
|
||||
.is_some_and(|spec| !spec.default_enabled);
|
||||
if enabled || profile_scoped || !is_default_false_feature {
|
||||
if enabled || !is_default_false_feature {
|
||||
self.edits.push(ConfigEdit::SetPath {
|
||||
segments,
|
||||
value: value(enabled),
|
||||
@@ -1291,18 +1210,8 @@ impl ConfigEditsBuilder {
|
||||
}
|
||||
|
||||
pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self {
|
||||
let segments = if let Some(profile) = self.profile.as_ref() {
|
||||
vec![
|
||||
"profiles".to_string(),
|
||||
profile.clone(),
|
||||
"windows".to_string(),
|
||||
"sandbox".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["windows".to_string(), "sandbox".to_string()]
|
||||
};
|
||||
self.edits.push(ConfigEdit::SetPath {
|
||||
segments,
|
||||
segments: vec!["windows".to_string(), "sandbox".to_string()],
|
||||
value: value(mode),
|
||||
});
|
||||
self
|
||||
@@ -1350,34 +1259,15 @@ impl ConfigEditsBuilder {
|
||||
"elevated_windows_sandbox",
|
||||
"enable_experimental_windows_sandbox",
|
||||
] {
|
||||
let mut segments = vec!["features".to_string(), key.to_string()];
|
||||
if let Some(profile) = self.profile.as_ref() {
|
||||
segments = vec![
|
||||
"profiles".to_string(),
|
||||
profile.clone(),
|
||||
"features".to_string(),
|
||||
key.to_string(),
|
||||
];
|
||||
}
|
||||
let segments = vec!["features".to_string(), key.to_string()];
|
||||
self.edits.push(ConfigEdit::ClearPath { segments });
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self {
|
||||
let segments = if let Some(profile) = self.profile.as_ref() {
|
||||
vec![
|
||||
"profiles".to_string(),
|
||||
profile.clone(),
|
||||
"tui".to_string(),
|
||||
"session_picker_view".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["tui".to_string(), "session_picker_view".to_string()]
|
||||
};
|
||||
|
||||
self.edits.push(ConfigEdit::SetPath {
|
||||
segments,
|
||||
segments: vec!["tui".to_string(), "session_picker_view".to_string()],
|
||||
value: value(mode.to_string()),
|
||||
});
|
||||
self
|
||||
@@ -1393,13 +1283,13 @@ impl ConfigEditsBuilder {
|
||||
|
||||
/// Apply edits on a blocking thread.
|
||||
pub fn apply_blocking(self) -> anyhow::Result<()> {
|
||||
apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits)
|
||||
apply_blocking_to_resolved_file(&self.config_path, &self.edits)
|
||||
}
|
||||
|
||||
/// Apply edits asynchronously via a blocking offload.
|
||||
pub async fn apply(self) -> anyhow::Result<()> {
|
||||
task::spawn_blocking(move || {
|
||||
apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits)
|
||||
apply_blocking_to_resolved_file(&self.config_path, &self.edits)
|
||||
})
|
||||
.await
|
||||
.context("config persistence task panicked")?
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_config::types::McpServerOAuthConfig;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::SessionPickerViewMode;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -19,7 +20,6 @@ fn blocking_set_model_top_level() {
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
@@ -34,6 +34,20 @@ model_reasoning_effort = "high"
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_service_tier_saves_default_as_default() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.set_service_tier(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
|
||||
.apply_blocking()
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
assert_eq!(contents, "service_tier = \"default\"\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_service_tier_saves_priority_as_fast() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
@@ -96,24 +110,6 @@ session_picker_view = "dense"
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_picker_view_builder_respects_active_profile() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_profile(Some("work"))
|
||||
.set_session_picker_view(SessionPickerViewMode::Dense)
|
||||
.apply_blocking()
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[profiles.work.tui]
|
||||
session_picker_view = "dense"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keymap_binding_edit_writes_root_action_binding() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
@@ -365,7 +361,7 @@ enabled = false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_model_preserves_inline_table_contents() {
|
||||
fn blocking_set_model_ignores_inline_legacy_profile_contents() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
@@ -381,7 +377,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("o4-mini".to_string()),
|
||||
effort: None,
|
||||
@@ -392,7 +387,12 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
||||
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let value: TomlValue = toml::from_str(&raw).expect("parse config");
|
||||
|
||||
// Ensure sandbox_mode is preserved under profiles.fast and model updated.
|
||||
assert_eq!(
|
||||
value.get("model").and_then(TomlValue::as_str),
|
||||
Some("o4-mini")
|
||||
);
|
||||
|
||||
// Legacy profile values stay untouched when root settings are updated.
|
||||
let profiles_tbl = value
|
||||
.get("profiles")
|
||||
.and_then(|v| v.as_table())
|
||||
@@ -407,7 +407,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
||||
);
|
||||
assert_eq!(
|
||||
fast_tbl.get("model").and_then(|v| v.as_str()),
|
||||
Some("o4-mini")
|
||||
Some("gpt-4o")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -426,7 +426,6 @@ fn blocking_set_model_writes_through_symlink_chain() {
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
@@ -459,7 +458,6 @@ fn blocking_set_model_replaces_symlink_on_cycle() {
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
effort: None,
|
||||
@@ -498,7 +496,6 @@ network_access = false
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
@@ -538,7 +535,7 @@ network_access = true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_clear_model_removes_inline_table_entry() {
|
||||
fn blocking_clear_model_does_not_follow_legacy_active_profile() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
@@ -553,7 +550,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: None,
|
||||
effort: Some(ReasoningEffort::High),
|
||||
@@ -564,15 +560,14 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"profile = "fast"
|
||||
|
||||
[profiles.fast]
|
||||
sandbox_mode = "strict"
|
||||
profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
||||
model_reasoning_effort = "high"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_model_scopes_to_active_profile() {
|
||||
fn blocking_set_model_does_not_follow_legacy_active_profile() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
std::fs::write(
|
||||
@@ -587,7 +582,6 @@ model_reasoning_effort = "low"
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("o5-preview".to_string()),
|
||||
effort: Some(ReasoningEffort::Minimal),
|
||||
@@ -597,39 +591,11 @@ model_reasoning_effort = "low"
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"profile = "team"
|
||||
model = "o5-preview"
|
||||
model_reasoning_effort = "minimal"
|
||||
|
||||
[profiles.team]
|
||||
model_reasoning_effort = "minimal"
|
||||
model = "o5-preview"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_model_with_explicit_profile() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"[profiles."team a"]
|
||||
model = "gpt-5.4"
|
||||
"#,
|
||||
)
|
||||
.expect("seed");
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
Some("team a"),
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("o4-mini".to_string()),
|
||||
effort: None,
|
||||
}],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[profiles."team a"]
|
||||
model = "o4-mini"
|
||||
model_reasoning_effort = "low"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
@@ -651,7 +617,6 @@ existing = "value"
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeHideFullAccessWarning(true)],
|
||||
)
|
||||
.expect("persist");
|
||||
@@ -681,7 +646,6 @@ existing = "value"
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)],
|
||||
)
|
||||
.expect("persist");
|
||||
@@ -707,7 +671,6 @@ existing = "value"
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
|
||||
"hide_gpt5_1_migration_prompt".to_string(),
|
||||
true,
|
||||
@@ -736,7 +699,6 @@ existing = "value"
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
|
||||
"hide_gpt-5.1-codex-max_migration_prompt".to_string(),
|
||||
true,
|
||||
@@ -765,7 +727,6 @@ existing = "value"
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::RecordModelMigrationSeen {
|
||||
from: "gpt-5.2".to_string(),
|
||||
to: "gpt-5.4".to_string(),
|
||||
@@ -796,7 +757,6 @@ existing = "value"
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
|
||||
true,
|
||||
)],
|
||||
@@ -826,7 +786,6 @@ existing = "value"
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[
|
||||
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
|
||||
"/Users/alexsong/code/skills".to_string(),
|
||||
@@ -859,7 +818,6 @@ existing = "value"
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)],
|
||||
)
|
||||
.expect("persist");
|
||||
@@ -887,7 +845,6 @@ existing = "value"
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[
|
||||
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
|
||||
"/Users/alexsong/code/skills".to_string(),
|
||||
@@ -930,7 +887,7 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
env_vars: vec!["FOO".into()],
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: true,
|
||||
@@ -960,7 +917,7 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
),
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: false,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -981,7 +938,6 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
|
||||
)
|
||||
.expect("persist");
|
||||
@@ -1032,7 +988,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -1054,12 +1010,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
|
||||
},
|
||||
);
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers)],
|
||||
)
|
||||
.expect("persist");
|
||||
apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
|
||||
|
||||
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = "\
|
||||
@@ -1097,7 +1048,7 @@ foo = { command = "cmd" }
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -1114,12 +1065,7 @@ foo = { command = "cmd" }
|
||||
},
|
||||
);
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers)],
|
||||
)
|
||||
.expect("persist");
|
||||
apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[mcp_servers]
|
||||
@@ -1152,7 +1098,7 @@ foo = { command = "cmd" } # keep me
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: false,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -1169,12 +1115,7 @@ foo = { command = "cmd" } # keep me
|
||||
},
|
||||
);
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers)],
|
||||
)
|
||||
.expect("persist");
|
||||
apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[mcp_servers]
|
||||
@@ -1206,7 +1147,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -1223,12 +1164,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
|
||||
},
|
||||
);
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers)],
|
||||
)
|
||||
.expect("persist");
|
||||
apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[mcp_servers]
|
||||
@@ -1261,7 +1197,7 @@ foo = { command = "cmd" }
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: false,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -1278,12 +1214,7 @@ foo = { command = "cmd" }
|
||||
},
|
||||
);
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers)],
|
||||
)
|
||||
.expect("persist");
|
||||
apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[mcp_servers]
|
||||
@@ -1300,7 +1231,6 @@ fn blocking_clear_path_noop_when_missing() {
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ClearPath {
|
||||
segments: vec!["missing".to_string()],
|
||||
}],
|
||||
@@ -1321,7 +1251,6 @@ fn blocking_set_path_updates_notifications() {
|
||||
let item = value(false);
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetPath {
|
||||
segments: vec!["tui".to_string(), "notifications".to_string()],
|
||||
value: item,
|
||||
@@ -1503,7 +1432,6 @@ fn replace_mcp_servers_blocking_clears_table_when_empty() {
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(BTreeMap::new())],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
@@ -9,7 +9,6 @@ use codex_config::RequirementSource;
|
||||
use codex_config::Sourced;
|
||||
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::profile_toml::ConfigProfile;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureConfigSource;
|
||||
use codex_features::FeatureOverrides;
|
||||
@@ -269,27 +268,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
for (profile_name, profile) in &cfg.profiles {
|
||||
if let Some(features) = profile.features.as_ref() {
|
||||
for (key, enabled) in features.entries() {
|
||||
if let Some(feature) = feature_for_key(&key) {
|
||||
explicit_settings.push((
|
||||
format!("profiles.{profile_name}.features.{key}"),
|
||||
feature,
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(enabled) = profile.experimental_use_unified_exec_tool {
|
||||
explicit_settings.push((
|
||||
format!("profiles.{profile_name}.experimental_use_unified_exec_tool"),
|
||||
Feature::UnifiedExec,
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
explicit_settings
|
||||
}
|
||||
|
||||
@@ -339,47 +317,13 @@ pub(crate) fn validate_feature_requirements_in_config_toml(
|
||||
cfg: &ConfigToml,
|
||||
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
|
||||
) -> std::io::Result<()> {
|
||||
fn validate_profile(
|
||||
cfg: &ConfigToml,
|
||||
profile_name: Option<&str>,
|
||||
profile: &ConfigProfile,
|
||||
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
|
||||
) -> std::io::Result<()> {
|
||||
let configured_features = Features::from_sources(
|
||||
FeatureConfigSource {
|
||||
features: cfg.features.as_ref(),
|
||||
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
|
||||
},
|
||||
FeatureConfigSource {
|
||||
features: profile.features.as_ref(),
|
||||
experimental_use_unified_exec_tool: profile.experimental_use_unified_exec_tool,
|
||||
},
|
||||
FeatureOverrides::default(),
|
||||
);
|
||||
ManagedFeatures::from_configured(configured_features, feature_requirements.cloned())
|
||||
.map(|_| ())
|
||||
.map_err(|err| {
|
||||
if let Some(profile_name) = profile_name {
|
||||
std::io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"invalid feature configuration for profile `{profile_name}`: {err}"
|
||||
),
|
||||
)
|
||||
} else {
|
||||
err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
validate_profile(
|
||||
cfg,
|
||||
/*profile_name*/ None,
|
||||
&ConfigProfile::default(),
|
||||
feature_requirements,
|
||||
)?;
|
||||
for (profile_name, profile) in &cfg.profiles {
|
||||
validate_profile(cfg, Some(profile_name), profile, feature_requirements)?;
|
||||
}
|
||||
Ok(())
|
||||
let configured_features = Features::from_sources(
|
||||
FeatureConfigSource {
|
||||
features: cfg.features.as_ref(),
|
||||
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
|
||||
},
|
||||
FeatureConfigSource::default(),
|
||||
FeatureOverrides::default(),
|
||||
);
|
||||
ManagedFeatures::from_configured(configured_features, feature_requirements.cloned()).map(|_| ())
|
||||
}
|
||||
|
||||
+517
-286
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,8 @@ use codex_network_proxy::NetworkDomainPermission;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
|
||||
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
|
||||
}
|
||||
|
||||
fn domain_permissions(
|
||||
entries: impl IntoIterator<Item = (&'static str, NetworkDomainPermissionToml)>,
|
||||
) -> NetworkDomainPermissionsToml {
|
||||
@@ -62,7 +57,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()),
|
||||
&PermissionProfile::read_only(),
|
||||
)
|
||||
.expect("config should stay within the managed allowlist");
|
||||
|
||||
@@ -97,7 +92,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed allowlist should not erase a user deny");
|
||||
|
||||
@@ -129,7 +124,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed baseline should still allow user edits");
|
||||
|
||||
@@ -207,7 +202,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("yolo mode should pin the effective policy to the managed baseline");
|
||||
|
||||
@@ -241,7 +236,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed baseline should still load");
|
||||
|
||||
@@ -270,7 +265,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed-only allowlist should still load");
|
||||
|
||||
@@ -300,7 +295,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed-only mode should treat missing managed allowlist as empty");
|
||||
|
||||
@@ -324,7 +319,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("managed-only mode should treat missing managed allowlist as empty");
|
||||
|
||||
@@ -351,7 +346,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("deny-only requirements should not constrain the allowlist");
|
||||
|
||||
@@ -384,7 +379,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("allow-only requirements should not constrain the denylist");
|
||||
|
||||
@@ -417,7 +412,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("default mode should merge managed and user deny entries");
|
||||
|
||||
@@ -452,7 +447,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed baseline should still allow user edits");
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
|
||||
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
use codex_config::permissions_toml::ResolvedPermissionProfileToml;
|
||||
use codex_config::permissions_toml::WorkspaceRootsToml;
|
||||
use codex_config::types::SandboxWorkspaceWrite;
|
||||
use codex_features::NetworkProxyConfigToml;
|
||||
@@ -190,15 +191,29 @@ pub(crate) fn apply_network_proxy_feature_config(
|
||||
.apply_to_network_proxy_config(config);
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_permission_profile<'a>(
|
||||
permissions: &'a PermissionsToml,
|
||||
pub(crate) fn resolve_permission_profile(
|
||||
permissions: &PermissionsToml,
|
||||
profile_name: &str,
|
||||
) -> io::Result<&'a PermissionProfileToml> {
|
||||
permissions.entries.get(profile_name).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("default_permissions refers to undefined profile `{profile_name}`"),
|
||||
)
|
||||
) -> io::Result<ResolvedPermissionProfileToml> {
|
||||
permissions
|
||||
.resolve_profile(profile_name, extensible_builtin_parent_profile_marker)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))
|
||||
}
|
||||
|
||||
/// Built-in parents provide their runtime permissions below. Resolution only
|
||||
/// needs an empty profile marker so inheritance can terminate while preserving
|
||||
/// the built-in parent id in `inherited_profile_names`.
|
||||
fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option<PermissionProfileToml> {
|
||||
matches!(
|
||||
profile_name,
|
||||
BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE
|
||||
)
|
||||
.then_some(PermissionProfileToml {
|
||||
description: None,
|
||||
extends: None,
|
||||
workspace_roots: None,
|
||||
filesystem: None,
|
||||
network: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -217,7 +232,7 @@ pub(crate) fn network_proxy_config_for_profile_selection(
|
||||
"default_permissions requires a `[permissions]` table",
|
||||
)
|
||||
})?;
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?;
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?.profile;
|
||||
Ok(network_proxy_config_from_profile_network(
|
||||
profile.network.as_ref(),
|
||||
))
|
||||
@@ -229,11 +244,27 @@ pub(crate) fn compile_permission_profile(
|
||||
policy_cwd: &Path,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let ResolvedPermissionProfileToml {
|
||||
profile,
|
||||
inherited_profile_names,
|
||||
} = resolve_permission_profile(permissions, profile_name)?;
|
||||
let base_permissions = inherited_profile_names.iter().find_map(|name| {
|
||||
match name.as_str() {
|
||||
BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()),
|
||||
BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()),
|
||||
_ => None,
|
||||
}
|
||||
.map(|profile| profile.to_runtime_permissions())
|
||||
});
|
||||
let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
FileSystemSandboxPolicy::restricted(Vec::new()),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
)
|
||||
});
|
||||
if let Some(filesystem) = profile.filesystem.as_ref() {
|
||||
if filesystem.is_empty() {
|
||||
if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() {
|
||||
push_warning(
|
||||
startup_warnings,
|
||||
missing_filesystem_entries_warning(profile_name),
|
||||
@@ -244,7 +275,7 @@ pub(crate) fn compile_permission_profile(
|
||||
push_warning(
|
||||
startup_warnings,
|
||||
format!(
|
||||
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported."
|
||||
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `deny` globs are supported."
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -258,15 +289,17 @@ pub(crate) fn compile_permission_profile(
|
||||
}
|
||||
}
|
||||
for (path, permission) in &filesystem.entries {
|
||||
entries.extend(compile_filesystem_permission(
|
||||
path,
|
||||
permission,
|
||||
policy_cwd,
|
||||
startup_warnings,
|
||||
)?);
|
||||
file_system_sandbox_policy
|
||||
.entries
|
||||
.extend(compile_filesystem_permission(
|
||||
path,
|
||||
permission,
|
||||
policy_cwd,
|
||||
startup_warnings,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if file_system_sandbox_policy.entries.is_empty() {
|
||||
push_warning(
|
||||
startup_warnings,
|
||||
missing_filesystem_entries_warning(profile_name),
|
||||
@@ -278,10 +311,11 @@ pub(crate) fn compile_permission_profile(
|
||||
.as_ref()
|
||||
.and_then(|filesystem| filesystem.glob_scan_max_depth),
|
||||
)?;
|
||||
|
||||
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
|
||||
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries);
|
||||
file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth;
|
||||
if let Some(glob_scan_max_depth) = glob_scan_max_depth {
|
||||
file_system_sandbox_policy.glob_scan_max_depth = Some(glob_scan_max_depth);
|
||||
}
|
||||
let network_sandbox_policy =
|
||||
compile_network_sandbox_policy(profile.network.as_ref(), base_network_sandbox_policy);
|
||||
Ok((file_system_sandbox_policy, network_sandbox_policy))
|
||||
}
|
||||
|
||||
@@ -324,7 +358,7 @@ pub(crate) fn compile_permission_profile_workspace_roots(
|
||||
})?;
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?;
|
||||
Ok(compile_workspace_roots(
|
||||
profile.workspace_roots.as_ref(),
|
||||
profile.profile.workspace_roots.as_ref(),
|
||||
policy_cwd,
|
||||
))
|
||||
}
|
||||
@@ -341,7 +375,7 @@ fn compile_workspace_roots(
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> {
|
||||
pub(crate) fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> {
|
||||
if profile_name.starts_with(':') {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
@@ -383,14 +417,18 @@ pub(crate) fn get_readable_roots_required_for_codex_runtime(
|
||||
readable_roots
|
||||
}
|
||||
|
||||
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
|
||||
fn compile_network_sandbox_policy(
|
||||
network: Option<&NetworkToml>,
|
||||
base_network_sandbox_policy: NetworkSandboxPolicy,
|
||||
) -> NetworkSandboxPolicy {
|
||||
let Some(network) = network else {
|
||||
return NetworkSandboxPolicy::Restricted;
|
||||
return base_network_sandbox_policy;
|
||||
};
|
||||
|
||||
match network.enabled {
|
||||
Some(true) => NetworkSandboxPolicy::Enabled,
|
||||
_ => NetworkSandboxPolicy::Restricted,
|
||||
Some(false) => NetworkSandboxPolicy::Restricted,
|
||||
None => base_network_sandbox_policy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +453,7 @@ fn compile_filesystem_permission(
|
||||
Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true,
|
||||
Some(_) => false,
|
||||
};
|
||||
if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern {
|
||||
if has_glob && *access == FileSystemAccessMode::Deny && can_compile_as_pattern {
|
||||
// Scoped glob syntax is a first-class filesystem policy
|
||||
// pattern entry. Literal scoped paths continue through the
|
||||
// exact-path parser so existing path semantics stay intact.
|
||||
@@ -450,7 +488,7 @@ fn compile_filesystem_access_path(
|
||||
return compile_filesystem_path(path, startup_warnings);
|
||||
}
|
||||
|
||||
if access == FileSystemAccessMode::None {
|
||||
if access == FileSystemAccessMode::Deny {
|
||||
// At this point `path` is an unscoped filesystem table key. Top-level
|
||||
// glob deny entries still go through the absolute-path parser before
|
||||
// becoming policy patterns; relative project-root glob syntax is
|
||||
@@ -521,10 +559,10 @@ fn compile_scoped_filesystem_pattern(
|
||||
// Pattern entries currently mean deny-read only. Supporting broader access
|
||||
// modes here would imply glob-based read/write allow semantics that the
|
||||
// sandbox policy does not express yet.
|
||||
if access != FileSystemAccessMode::None {
|
||||
if access != FileSystemAccessMode::Deny {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("filesystem glob subpath `{subpath}` only supports `none` access"),
|
||||
format!("filesystem glob subpath `{subpath}` only supports `deny` access"),
|
||||
));
|
||||
}
|
||||
let subpath = parse_relative_subpath(subpath)?;
|
||||
@@ -560,7 +598,7 @@ fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io:
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access"
|
||||
"filesystem glob path `{path}` only supports `deny` access; use an exact path or trailing `/**` for `{access}` subtree access"
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -570,7 +608,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) ->
|
||||
for (path, permission) in &filesystem.entries {
|
||||
match permission {
|
||||
FilesystemPermissionToml::Access(access) => {
|
||||
if *access != FileSystemAccessMode::None
|
||||
if *access != FileSystemAccessMode::Deny
|
||||
&& contains_glob_chars(remove_trailing_glob_suffix(path))
|
||||
{
|
||||
patterns.push(path.clone());
|
||||
@@ -578,7 +616,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) ->
|
||||
}
|
||||
FilesystemPermissionToml::Scoped(scoped_entries) => {
|
||||
for (subpath, access) in scoped_entries {
|
||||
if *access != FileSystemAccessMode::None
|
||||
if *access != FileSystemAccessMode::Deny
|
||||
&& contains_glob_chars(remove_trailing_glob_suffix(subpath))
|
||||
{
|
||||
patterns.push(format!("{path}/{subpath}"));
|
||||
@@ -598,7 +636,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -
|
||||
let mut patterns = Vec::new();
|
||||
for (path, permission) in &filesystem.entries {
|
||||
match permission {
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::None) => {
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::Deny) => {
|
||||
if path.contains("**") {
|
||||
patterns.push(path.clone());
|
||||
}
|
||||
@@ -606,7 +644,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -
|
||||
FilesystemPermissionToml::Access(_) => {}
|
||||
FilesystemPermissionToml::Scoped(scoped_entries) => {
|
||||
for (subpath, access) in scoped_entries {
|
||||
if *access == FileSystemAccessMode::None && subpath.contains("**") {
|
||||
if *access == FileSystemAccessMode::Deny && subpath.contains("**") {
|
||||
patterns.push(format!("{path}/{subpath}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu
|
||||
entries: BTreeMap::from([(
|
||||
"workspace".to_string(),
|
||||
PermissionProfileToml {
|
||||
description: None,
|
||||
extends: None,
|
||||
workspace_roots: None,
|
||||
filesystem: Some(FilesystemPermissionsToml {
|
||||
glob_scan_max_depth: None,
|
||||
@@ -80,7 +82,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu
|
||||
},
|
||||
ConfigOverrides {
|
||||
cwd: Some(cwd.clone()),
|
||||
zsh_path: Some(zsh_path.clone()),
|
||||
default_zsh_path: Some(AbsolutePathBuf::try_from(zsh_path.clone())?),
|
||||
main_execve_wrapper_exe: Some(execve_wrapper),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -238,6 +240,157 @@ fn network_toml_overlays_unix_socket_permissions_by_path() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profiles_resolve_extends_parent_first_with_child_overrides() {
|
||||
let permissions = toml::from_str::<PermissionsToml>(
|
||||
r#"
|
||||
[base]
|
||||
description = "Base profile"
|
||||
|
||||
[base.filesystem]
|
||||
glob_scan_max_depth = 1
|
||||
"/tmp/base" = "read"
|
||||
"/tmp/shared" = "read"
|
||||
|
||||
[base.filesystem.":project_roots"]
|
||||
"**/*.env" = "deny"
|
||||
docs = "read"
|
||||
|
||||
[base.network]
|
||||
enabled = true
|
||||
|
||||
[base.network.domains]
|
||||
"base.example.com" = "allow"
|
||||
"SHARED.EXAMPLE.COM." = "deny"
|
||||
|
||||
[base.network.unix_sockets]
|
||||
"/tmp/base.sock" = "allow"
|
||||
|
||||
[child]
|
||||
extends = "base"
|
||||
|
||||
[child.filesystem]
|
||||
glob_scan_max_depth = 3
|
||||
"/tmp/shared" = "write"
|
||||
|
||||
[child.filesystem.":project_roots"]
|
||||
docs = "write"
|
||||
src = "read"
|
||||
|
||||
[child.network]
|
||||
enabled = false
|
||||
allow_local_binding = true
|
||||
|
||||
[child.network.domains]
|
||||
"child.example.com" = "allow"
|
||||
"shared.example.com" = "allow"
|
||||
|
||||
[child.network.unix_sockets]
|
||||
"/tmp/child.sock" = "allow"
|
||||
"#,
|
||||
)
|
||||
.expect("permissions should deserialize");
|
||||
|
||||
let resolved = permissions
|
||||
.resolve_profile("child", |_| None)
|
||||
.expect("child profile should resolve");
|
||||
let expected_profile = toml::from_str::<PermissionProfileToml>(
|
||||
r#"
|
||||
extends = "base"
|
||||
|
||||
[filesystem]
|
||||
glob_scan_max_depth = 3
|
||||
"/tmp/base" = "read"
|
||||
"/tmp/shared" = "write"
|
||||
|
||||
[filesystem.":project_roots"]
|
||||
"**/*.env" = "deny"
|
||||
docs = "write"
|
||||
src = "read"
|
||||
|
||||
[network]
|
||||
enabled = false
|
||||
allow_local_binding = true
|
||||
|
||||
[network.domains]
|
||||
"base.example.com" = "allow"
|
||||
"child.example.com" = "allow"
|
||||
"shared.example.com" = "allow"
|
||||
|
||||
[network.unix_sockets]
|
||||
"/tmp/base.sock" = "allow"
|
||||
"/tmp/child.sock" = "allow"
|
||||
"#,
|
||||
)
|
||||
.expect("expected profile should deserialize");
|
||||
|
||||
assert_eq!(resolved.profile, expected_profile);
|
||||
assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profiles_reject_undefined_extends_parent() {
|
||||
let permissions = toml::from_str::<PermissionsToml>(
|
||||
r#"
|
||||
[child]
|
||||
extends = "base"
|
||||
"#,
|
||||
)
|
||||
.expect("permissions should deserialize");
|
||||
|
||||
let err = permissions
|
||||
.resolve_profile("child", |_| None)
|
||||
.expect_err("missing parent should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"permissions profile `child` extends undefined profile `base`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profiles_reject_unsupported_builtin_extends_parent() {
|
||||
let permissions = toml::from_str::<PermissionsToml>(
|
||||
r#"
|
||||
[child]
|
||||
extends = ":danger-full-access"
|
||||
"#,
|
||||
)
|
||||
.expect("permissions should deserialize");
|
||||
|
||||
let err = permissions
|
||||
.resolve_profile("child", |_| None)
|
||||
.expect_err("unsupported built-in parent should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"permissions profile `child` cannot extend unsupported built-in profile `:danger-full-access`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profiles_reject_extends_cycles() {
|
||||
let permissions = toml::from_str::<PermissionsToml>(
|
||||
r#"
|
||||
[alpha]
|
||||
extends = "beta"
|
||||
|
||||
[beta]
|
||||
extends = "alpha"
|
||||
"#,
|
||||
)
|
||||
.expect("permissions should deserialize");
|
||||
|
||||
let err = permissions
|
||||
.resolve_profile("alpha", |_| None)
|
||||
.expect_err("cycle should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"permissions profile inheritance cycle detected: alpha -> beta -> alpha"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() {
|
||||
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {
|
||||
@@ -285,6 +438,8 @@ fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std:
|
||||
entries: BTreeMap::from([(
|
||||
"workspace".to_string(),
|
||||
PermissionProfileToml {
|
||||
description: None,
|
||||
extends: None,
|
||||
workspace_roots: Some(WorkspaceRootsToml {
|
||||
entries: BTreeMap::from([
|
||||
("backend".to_string(), true),
|
||||
@@ -326,7 +481,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths
|
||||
(
|
||||
":workspace_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
||||
("**/*.env".to_string(), FileSystemAccessMode::None),
|
||||
("**/*.env".to_string(), FileSystemAccessMode::Deny),
|
||||
("docs/**".to_string(), FileSystemAccessMode::Read),
|
||||
("src/**/*.rs".to_string(), FileSystemAccessMode::Write),
|
||||
])),
|
||||
@@ -340,7 +495,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths
|
||||
"/tmp/**/*.log".to_string(),
|
||||
":workspace_roots/src/**/*.rs".to_string()
|
||||
],
|
||||
"`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
|
||||
"`deny` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -351,8 +506,8 @@ fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() {
|
||||
entries: BTreeMap::from([(
|
||||
":workspace_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
||||
("**/*.env".to_string(), FileSystemAccessMode::None),
|
||||
("*.pem".to_string(), FileSystemAccessMode::None),
|
||||
("**/*.env".to_string(), FileSystemAccessMode::Deny),
|
||||
("*.pem".to_string(), FileSystemAccessMode::Deny),
|
||||
])),
|
||||
)]),
|
||||
};
|
||||
@@ -394,6 +549,8 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()>
|
||||
entries: BTreeMap::from([(
|
||||
"workspace".to_string(),
|
||||
PermissionProfileToml {
|
||||
description: None,
|
||||
extends: None,
|
||||
workspace_roots: None,
|
||||
filesystem: Some(FilesystemPermissionsToml {
|
||||
glob_scan_max_depth: None,
|
||||
@@ -435,7 +592,7 @@ fn read_write_glob_patterns_still_reject_non_subpath_globs() {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("filesystem glob path `src/**/*.rs` only supports `none` access"),
|
||||
.contains("filesystem glob path `src/**/*.rs` only supports `deny` access"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,19 @@ pub(crate) enum ResolvedPermissionProfile {
|
||||
Named(NamedPermissionProfile),
|
||||
}
|
||||
|
||||
/// Trusted snapshot of a resolved permission profile.
|
||||
///
|
||||
/// This is a bridge for already-resolved session/config state. It keeps the
|
||||
/// concrete `PermissionProfile`, optional active profile id, and
|
||||
/// profile-defined workspace roots together so `Permissions` can validate and
|
||||
/// install them atomically. It is not a resolver: callers that are handling
|
||||
/// user-selected profile ids should resolve those ids through config instead
|
||||
/// of constructing this type directly.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PermissionProfileSnapshot {
|
||||
resolved_permission_profile: ResolvedPermissionProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LegacyPermissionProfile {
|
||||
permission_profile: PermissionProfile,
|
||||
@@ -124,6 +137,93 @@ impl ResolvedPermissionProfile {
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionProfileSnapshot {
|
||||
/// Create a snapshot with no active profile id.
|
||||
///
|
||||
/// Prefer this only for legacy data or local overrides that genuinely do
|
||||
/// not have a named/built-in profile identity. Using this for a built-in or
|
||||
/// named profile will intentionally clear the active profile metadata.
|
||||
pub fn legacy(permission_profile: PermissionProfile) -> Self {
|
||||
Self {
|
||||
resolved_permission_profile: ResolvedPermissionProfile::legacy(permission_profile),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a snapshot for a known active profile id.
|
||||
///
|
||||
/// Use this only after a trusted caller has already resolved the active id
|
||||
/// to the supplied concrete `PermissionProfile`. This constructor does not
|
||||
/// verify that the id and profile match; `Permissions` will still enforce
|
||||
/// configured permission constraints when the snapshot is installed.
|
||||
pub fn active(
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: ActivePermissionProfile,
|
||||
) -> Self {
|
||||
Self::active_with_profile_workspace_roots(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a snapshot for a known active profile id with profile roots.
|
||||
///
|
||||
/// As with `active`, the caller is responsible for passing the concrete
|
||||
/// profile and active id that were resolved together. Use this variant when
|
||||
/// the selected profile declared workspace roots that should remain
|
||||
/// distinct from turn-scoped runtime workspace roots.
|
||||
pub fn active_with_profile_workspace_roots(
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: ActivePermissionProfile,
|
||||
profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolved_permission_profile: ResolvedPermissionProfile::from_active_profile(
|
||||
permission_profile,
|
||||
Some(active_permission_profile),
|
||||
profile_workspace_roots,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct a trusted snapshot from session state.
|
||||
///
|
||||
/// This is intended for session responses emitted by core, where the
|
||||
/// concrete profile and active profile id were captured together. Avoid
|
||||
/// using this as a shortcut for arbitrary user input because mismatched
|
||||
/// arguments can still misrepresent the active profile identity.
|
||||
pub fn from_session_snapshot(
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
) -> Self {
|
||||
match active_permission_profile {
|
||||
Some(active_permission_profile) => {
|
||||
Self::active(permission_profile, active_permission_profile)
|
||||
}
|
||||
None => Self::legacy(permission_profile),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the concrete permission profile captured in this snapshot.
|
||||
pub fn permission_profile(&self) -> &PermissionProfile {
|
||||
self.resolved_permission_profile.permission_profile()
|
||||
}
|
||||
|
||||
/// Return the active profile id captured in this snapshot, if any.
|
||||
pub fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
|
||||
self.resolved_permission_profile.active_permission_profile()
|
||||
}
|
||||
|
||||
/// Borrow profile-declared workspace roots captured in this snapshot.
|
||||
pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] {
|
||||
self.resolved_permission_profile.profile_workspace_roots()
|
||||
}
|
||||
|
||||
pub(crate) fn into_resolved_permission_profile(self) -> ResolvedPermissionProfile {
|
||||
self.resolved_permission_profile
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct PermissionProfileState {
|
||||
resolved_permission_profile: Constrained<ResolvedPermissionProfile>,
|
||||
@@ -199,17 +299,11 @@ impl PermissionProfileState {
|
||||
.set(ResolvedPermissionProfile::legacy(permission_profile))
|
||||
}
|
||||
|
||||
pub(crate) fn set_active_permission_profile(
|
||||
pub(crate) fn set_permission_profile_snapshot(
|
||||
&mut self,
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
snapshot: PermissionProfileSnapshot,
|
||||
) -> ConstraintResult<()> {
|
||||
let candidate = ResolvedPermissionProfile::from_active_profile(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
profile_workspace_roots,
|
||||
);
|
||||
self.resolved_permission_profile.set(candidate)
|
||||
self.resolved_permission_profile
|
||||
.set(snapshot.into_resolved_permission_profile())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ use codex_login::CodexAuth;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::McpRuntimeEnvironment;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_mcp::codex_apps_tools_cache_key;
|
||||
@@ -198,11 +198,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
config.codex_linux_sandbox_exe.clone(),
|
||||
)?;
|
||||
let environment_manager =
|
||||
EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?;
|
||||
EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths))
|
||||
.await?;
|
||||
list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
config,
|
||||
force_refetch,
|
||||
&environment_manager,
|
||||
Arc::new(environment_manager),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -210,7 +211,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
config: &Config,
|
||||
force_refetch: bool,
|
||||
environment_manager: &EnvironmentManager,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
) -> anyhow::Result<AccessibleConnectorsStatus> {
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
|
||||
@@ -261,10 +262,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
|
||||
let environment = environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| environment_manager.local_environment());
|
||||
|
||||
let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
@@ -273,10 +270,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event,
|
||||
PermissionProfile::default(),
|
||||
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
|
||||
// Connector discovery is threadless. Use an actually configured env if
|
||||
// one exists, but do not reintroduce the old hidden-local fallback.
|
||||
McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()),
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
host_owned_codex_apps_enabled,
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
mcp_config.client_elicitation_capability,
|
||||
ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
|
||||
@@ -14,9 +14,17 @@ impl ApprovedCommandPrefixSaved {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ApprovedCommandPrefixSaved {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("Approved command prefix saved:\n{}", self.prefixes)
|
||||
|
||||
@@ -18,9 +18,17 @@ impl AppsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AppsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
const END_MARKER: &'static str = APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(APPS_INSTRUCTIONS_OPEN_TAG, APPS_INSTRUCTIONS_CLOSE_TAG)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -22,9 +22,20 @@ impl AvailablePluginsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AvailablePluginsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
||||
const END_MARKER: &'static str = PLUGINS_INSTRUCTIONS_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
PLUGINS_INSTRUCTIONS_OPEN_TAG,
|
||||
PLUGINS_INSTRUCTIONS_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let mut lines = vec![
|
||||
|
||||
@@ -21,9 +21,17 @@ impl From<AvailableSkills> for AvailableSkillsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AvailableSkillsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
render_available_skills_body(&self.skill_root_lines, &self.skill_lines)
|
||||
|
||||
@@ -22,9 +22,17 @@ impl CollaborationModeInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for CollaborationModeInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = COLLABORATION_MODE_OPEN_TAG;
|
||||
const END_MARKER: &'static str = COLLABORATION_MODE_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(COLLABORATION_MODE_OPEN_TAG, COLLABORATION_MODE_CLOSE_TAG)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.instructions.clone()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::*;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::GoalContext;
|
||||
use crate::context::SubagentNotification;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
@@ -28,16 +31,25 @@ fn detects_subagent_notification_fragment_case_insensitively() {
|
||||
|
||||
#[test]
|
||||
fn detects_goal_context_fragment() {
|
||||
let text = GoalContext {
|
||||
prompt: "Continue working toward the active thread goal.".to_string(),
|
||||
}
|
||||
.render();
|
||||
let text = GoalContext::new("Continue working toward the active thread goal.").render();
|
||||
|
||||
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
||||
text
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_user_fragment_is_dyn_compatible() {
|
||||
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext::new(
|
||||
"Continue working toward the active thread goal.",
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
fragment.render(),
|
||||
"<goal_context>\nContinue working toward the active thread goal.\n</goal_context>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_regular_user_text() {
|
||||
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
|
||||
|
||||
@@ -269,9 +269,20 @@ impl EnvironmentContext {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for EnvironmentContext {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
const END_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG,
|
||||
codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
//! Hidden user-context fragment for runtime-owned goal steering prompts.
|
||||
|
||||
use super::ContextualUserFragment;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
/// Hidden runtime-owned goal steering context injected into model input.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct GoalContext {
|
||||
pub(crate) prompt: String,
|
||||
pub struct GoalContext {
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
impl GoalContext {
|
||||
/// Creates goal context around an already-rendered steering prompt.
|
||||
pub fn new(prompt: impl Into<String>) -> Self {
|
||||
Self {
|
||||
prompt: prompt.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the registered fragment into an active-turn injectable item.
|
||||
pub fn into_response_input_item(self) -> ResponseInputItem {
|
||||
ResponseInputItem::Message {
|
||||
role: <Self as ContextualUserFragment>::role().to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: self.render(),
|
||||
}],
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for GoalContext {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<goal_context>";
|
||||
const END_MARKER: &'static str = "</goal_context>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<goal_context>", "</goal_context>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n", self.prompt)
|
||||
|
||||
@@ -4,9 +4,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct GuardianFollowupReviewReminder;
|
||||
|
||||
impl ContextualUserFragment for GuardianFollowupReviewReminder {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
concat!(
|
||||
|
||||
@@ -12,9 +12,17 @@ impl HookAdditionalContext {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for HookAdditionalContext {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
|
||||
@@ -17,9 +17,17 @@ impl ImageGenerationInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ImageGenerationInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyApplyPatchExecCommandWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn matches_text(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
|
||||
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyModelMismatchWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyModelMismatchWarning {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn matches_text(text: &str) -> bool {
|
||||
text.trim().starts_with(
|
||||
|
||||
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyUnifiedExecProcessLimitWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn matches_text(text: &str) -> bool {
|
||||
text.trim().starts_with(
|
||||
|
||||
@@ -40,7 +40,7 @@ pub(crate) use environment_context::EnvironmentContext;
|
||||
pub use fragment::ContextualUserFragment;
|
||||
pub(crate) use fragment::FragmentRegistration;
|
||||
pub(crate) use fragment::FragmentRegistrationProxy;
|
||||
pub(crate) use goal_context::GoalContext;
|
||||
pub use goal_context::GoalContext;
|
||||
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
|
||||
pub(crate) use hook_additional_context::HookAdditionalContext;
|
||||
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
|
||||
|
||||
@@ -14,9 +14,17 @@ impl ModelSwitchInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ModelSwitchInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "<model_switch>";
|
||||
const END_MARKER: &'static str = "</model_switch>";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<model_switch>", "</model_switch>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -18,9 +18,17 @@ impl NetworkRuleSaved {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for NetworkRuleSaved {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let (action, list_name) = match self.action {
|
||||
|
||||
@@ -8,7 +8,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::WritableRoot;
|
||||
use codex_utils_template::Template;
|
||||
use std::path::Path;
|
||||
@@ -85,27 +84,6 @@ impl PermissionsInstructions {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds permissions instructions from a legacy sandbox policy.
|
||||
pub fn from_policy(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
approval_policy: AskForApproval,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
exec_policy: &Policy,
|
||||
cwd: &Path,
|
||||
exec_permission_approvals_enabled: bool,
|
||||
request_permissions_tool_enabled: bool,
|
||||
) -> Self {
|
||||
Self::from_permission_profile(
|
||||
&PermissionProfile::from_legacy_sandbox_policy(sandbox_policy),
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
exec_policy,
|
||||
cwd,
|
||||
exec_permission_approvals_enabled,
|
||||
request_permissions_tool_enabled,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_permissions_with_network(
|
||||
sandbox_mode: SandboxMode,
|
||||
network_access: NetworkAccess,
|
||||
@@ -167,9 +145,17 @@ fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAc
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PermissionsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "<permissions instructions>";
|
||||
const END_MARKER: &'static str = "</permissions instructions>";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<permissions instructions>", "</permissions instructions>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
|
||||
@@ -53,29 +53,6 @@ fn builds_permissions_with_network_access_override() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_permissions_from_policy() {
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
|
||||
let instructions = PermissionsInstructions::from_policy(
|
||||
&policy,
|
||||
AskForApproval::UnlessTrusted,
|
||||
ApprovalsReviewer::User,
|
||||
&Policy::empty(),
|
||||
&PathBuf::from("/tmp"),
|
||||
/*exec_permission_approvals_enabled*/ false,
|
||||
/*request_permissions_tool_enabled*/ false,
|
||||
);
|
||||
let text = instructions.body();
|
||||
assert!(text.contains("Network access is enabled."));
|
||||
assert!(text.contains("`approval_policy` is `unless-trusted`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_permissions_from_profile() {
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
|
||||
@@ -12,9 +12,17 @@ impl PersonalitySpecInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PersonalitySpecInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "<personality_spec>";
|
||||
const END_MARKER: &'static str = "</personality_spec>";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<personality_spec>", "</personality_spec>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -12,9 +12,17 @@ impl PluginInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PluginInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
|
||||
@@ -18,9 +18,20 @@ impl RealtimeEndInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for RealtimeEndInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
|
||||
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -8,9 +8,20 @@ const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtim
|
||||
pub(crate) struct RealtimeStartInstructions;
|
||||
|
||||
impl ContextualUserFragment for RealtimeStartInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
|
||||
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n", REALTIME_START_INSTRUCTIONS.trim())
|
||||
|
||||
@@ -16,9 +16,20 @@ impl RealtimeStartWithInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for RealtimeStartWithInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
|
||||
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n", self.instructions)
|
||||
|
||||
@@ -20,9 +20,17 @@ impl From<&SkillInjection> for SkillInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for SkillInstructions {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<skill>";
|
||||
const END_MARKER: &'static str = "</skill>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<skill>", "</skill>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -18,9 +18,17 @@ impl SubagentNotification {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for SubagentNotification {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<subagent_notification>";
|
||||
const END_MARKER: &'static str = "</subagent_notification>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<subagent_notification>", "</subagent_notification>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -126,6 +126,11 @@ impl ContextManager {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// Returns raw items in the history and consumes the snapshot.
|
||||
pub(crate) fn into_raw_items(self) -> Vec<ResponseItem> {
|
||||
self.items
|
||||
}
|
||||
|
||||
pub(crate) fn history_version(&self) -> u64 {
|
||||
self.history_version
|
||||
}
|
||||
@@ -504,6 +509,10 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize {
|
||||
.saturating_sub(650)
|
||||
}
|
||||
|
||||
fn estimate_encrypted_function_output_length(encoded_len: usize) -> usize {
|
||||
encoded_len.saturating_mul(9).div_ceil(16)
|
||||
}
|
||||
|
||||
fn estimate_item_token_count(item: &ResponseItem) -> i64 {
|
||||
let model_visible_bytes = estimate_response_item_model_visible_bytes(item);
|
||||
approx_tokens_from_byte_count_i64(model_visible_bytes)
|
||||
@@ -547,16 +556,18 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) ->
|
||||
let raw = serde_json::to_string(item)
|
||||
.map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX))
|
||||
.unwrap_or_default();
|
||||
let (payload_bytes, replacement_bytes) = image_data_url_estimate_adjustment(item);
|
||||
if payload_bytes == 0 || replacement_bytes == 0 {
|
||||
raw
|
||||
} else {
|
||||
// Replace raw base64 payload bytes with a per-image estimate.
|
||||
// We intentionally preserve the data URL prefix and JSON
|
||||
// wrapper bytes already included in `raw`.
|
||||
raw.saturating_sub(payload_bytes)
|
||||
.saturating_add(replacement_bytes)
|
||||
}
|
||||
let (image_payload_bytes, image_replacement_bytes) =
|
||||
image_data_url_estimate_adjustment(item);
|
||||
let (encrypted_payload_bytes, encrypted_replacement_bytes) =
|
||||
encrypted_function_output_estimate_adjustment(item);
|
||||
// Replace raw base64 payload bytes with a per-image estimate.
|
||||
// We intentionally preserve the data URL prefix and JSON
|
||||
// wrapper bytes already included in `raw`.
|
||||
let raw = raw
|
||||
.saturating_sub(image_payload_bytes)
|
||||
.saturating_add(image_replacement_bytes);
|
||||
raw.saturating_sub(encrypted_payload_bytes)
|
||||
.saturating_add(encrypted_replacement_bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,6 +689,31 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
|
||||
(payload_bytes, replacement_bytes)
|
||||
}
|
||||
|
||||
fn encrypted_function_output_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
|
||||
let ResponseItem::FunctionCallOutput { output, .. } = item else {
|
||||
return (0, 0);
|
||||
};
|
||||
let FunctionCallOutputBody::ContentItems(items) = &output.body else {
|
||||
return (0, 0);
|
||||
};
|
||||
|
||||
items.iter().fold((0i64, 0i64), |acc, item| {
|
||||
let FunctionCallOutputContentItem::EncryptedContent { encrypted_content } = item else {
|
||||
return acc;
|
||||
};
|
||||
let payload_bytes = acc
|
||||
.0
|
||||
.saturating_add(i64::try_from(encrypted_content.len()).unwrap_or(i64::MAX));
|
||||
let replacement_bytes = acc.1.saturating_add(
|
||||
i64::try_from(estimate_encrypted_function_output_length(
|
||||
encrypted_content.len(),
|
||||
))
|
||||
.unwrap_or(i64::MAX),
|
||||
);
|
||||
(payload_bytes, replacement_bytes)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_model_generated_item(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, .. } => role == "assistant",
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::*;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
@@ -121,7 +120,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
|
||||
fn reference_context_item() -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some("reference-turn".to_string()),
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/tmp/reference-cwd"),
|
||||
current_date: Some("2026-03-23".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
@@ -135,11 +133,7 @@ fn reference_context_item() -> TurnContextItem {
|
||||
collaboration_mode: None,
|
||||
realtime_active: Some(false),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1760,6 +1754,26 @@ fn non_base64_image_urls_are_unchanged() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_function_output_uses_plaintext_byte_estimate() {
|
||||
let encrypted_content = "A".repeat(1_868);
|
||||
let item = ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-encrypted".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: encrypted_content.clone(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
|
||||
let estimated = estimate_response_item_model_visible_bytes(&item);
|
||||
let expected = raw_len - encrypted_content.len() as i64
|
||||
+ estimate_encrypted_function_output_length(encrypted_content.len()) as i64;
|
||||
|
||||
assert_eq!(estimated, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_url_without_base64_marker_is_unchanged() {
|
||||
let item = ResponseItem::Message {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -48,8 +48,14 @@ fn parses_user_message_with_text_and_two_images() {
|
||||
text: "Hello world".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Image { image_url: img1 },
|
||||
UserInput::Image { image_url: img2 },
|
||||
UserInput::Image {
|
||||
image_url: img1,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
UserInput::Image {
|
||||
image_url: img2,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
];
|
||||
assert_eq!(user.content, expected_content);
|
||||
}
|
||||
@@ -87,7 +93,10 @@ fn skips_local_image_label_text() {
|
||||
match turn_item {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Image {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: user_text,
|
||||
text_elements: Vec::new(),
|
||||
@@ -165,7 +174,10 @@ fn skips_unnamed_image_label_text() {
|
||||
match turn_item {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Image {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: user_text,
|
||||
text_elements: Vec::new(),
|
||||
@@ -310,10 +322,7 @@ fn goal_context_does_not_parse_as_visible_turn_item() {
|
||||
id: Some("msg-1".to_string()),
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: GoalContext {
|
||||
prompt: "Continue working toward the active thread goal.".to_string(),
|
||||
}
|
||||
.render(),
|
||||
text: GoalContext::new("Continue working toward the active thread goal.").render(),
|
||||
}],
|
||||
phase: None,
|
||||
};
|
||||
|
||||
+24
-10
@@ -433,10 +433,10 @@ pub(crate) async fn execute_exec_request(
|
||||
expiration,
|
||||
capture_policy,
|
||||
sandbox,
|
||||
windows_sandbox_policy_cwd: _,
|
||||
windows_sandbox_policy_cwd,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
permission_profile: _,
|
||||
permission_profile,
|
||||
file_system_sandbox_policy: _,
|
||||
network_sandbox_policy,
|
||||
windows_sandbox_filesystem_overrides,
|
||||
@@ -465,6 +465,8 @@ pub(crate) async fn execute_exec_request(
|
||||
after_spawn,
|
||||
sandbox,
|
||||
&sandbox_policy,
|
||||
&permission_profile,
|
||||
&windows_sandbox_policy_cwd,
|
||||
windows_sandbox_filesystem_overrides.as_ref(),
|
||||
)
|
||||
.await;
|
||||
@@ -472,6 +474,7 @@ pub(crate) async fn execute_exec_request(
|
||||
finalize_exec_result(raw_output_result, sandbox, duration)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn get_raw_output_result(
|
||||
params: ExecParams,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
@@ -479,14 +482,22 @@ async fn get_raw_output_result(
|
||||
after_spawn: Option<Box<dyn FnOnce() + Send>>,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] sandbox: SandboxType,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] sandbox_policy: &SandboxPolicy,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] permission_profile: &PermissionProfile,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_policy_cwd: &AbsolutePathBuf,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_filesystem_overrides: Option<
|
||||
&WindowsSandboxFilesystemOverrides,
|
||||
>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if sandbox == SandboxType::WindowsRestrictedToken {
|
||||
return exec_windows_sandbox(params, sandbox_policy, windows_sandbox_filesystem_overrides)
|
||||
.await;
|
||||
return exec_windows_sandbox(
|
||||
params,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_policy_cwd,
|
||||
windows_sandbox_filesystem_overrides,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
exec(params, network_sandbox_policy, stdout_stream, after_spawn).await
|
||||
@@ -562,10 +573,12 @@ fn record_windows_sandbox_spawn_failure(
|
||||
async fn exec_windows_sandbox(
|
||||
params: ExecParams,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
permission_profile: &PermissionProfile,
|
||||
windows_sandbox_policy_cwd: &AbsolutePathBuf,
|
||||
windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
use crate::config::find_codex_home;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture_for_permission_profile_elevated;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture_with_filesystem_overrides;
|
||||
|
||||
let ExecParams {
|
||||
@@ -596,7 +609,8 @@ async fn exec_windows_sandbox(
|
||||
"failed to serialize Windows sandbox policy: {err}"
|
||||
)))
|
||||
})?;
|
||||
let sandbox_cwd = cwd.clone();
|
||||
let sandbox_cwd = windows_sandbox_policy_cwd.clone();
|
||||
let permission_profile = permission_profile.clone();
|
||||
let codex_home = find_codex_home().map_err(|err| {
|
||||
CodexErr::Io(io::Error::other(format!(
|
||||
"windows sandbox: failed to resolve codex_home: {err}"
|
||||
@@ -620,10 +634,10 @@ async fn exec_windows_sandbox(
|
||||
.and_then(|overrides| overrides.write_roots_override.clone());
|
||||
let spawn_res = tokio::task::spawn_blocking(move || {
|
||||
if use_elevated {
|
||||
run_windows_sandbox_capture_elevated(
|
||||
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
|
||||
policy_json_or_preset: policy_str.as_str(),
|
||||
sandbox_policy_cwd: &sandbox_cwd,
|
||||
run_windows_sandbox_capture_for_permission_profile_elevated(
|
||||
codex_windows_sandbox::ElevatedSandboxProfileCaptureRequest {
|
||||
permission_profile: &permission_profile,
|
||||
permission_profile_cwd: &sandbox_cwd,
|
||||
codex_home: codex_home.as_ref(),
|
||||
command,
|
||||
cwd: &cwd,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use codex_exec_server::LOCAL_FS;
|
||||
use codex_git_utils::GitInfo;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_git_utils::collect_git_info;
|
||||
use codex_git_utils::get_git_repo_root_with_fs;
|
||||
use codex_git_utils::get_has_changes;
|
||||
use codex_git_utils::git_diff_to_remote;
|
||||
use codex_git_utils::recent_commits;
|
||||
@@ -379,6 +380,50 @@ async fn test_get_has_changes_ignores_repo_fsmonitor_config() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_get_has_changes_ignores_configured_hooks_path() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
let hooks_dir = repo_path.join(".git/hooks-path-test");
|
||||
let hook_path = hooks_dir.join("post-index-change");
|
||||
let marker_path = repo_path.join("hook-ran");
|
||||
|
||||
fs::create_dir_all(&hooks_dir).expect("create hook dir");
|
||||
fs::write(
|
||||
&hook_path,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf ran > \"{}\"\n",
|
||||
marker_path.to_string_lossy()
|
||||
),
|
||||
)
|
||||
.expect("write post-index-change hook");
|
||||
let mut permissions = fs::metadata(&hook_path)
|
||||
.expect("read hook metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&hook_path, permissions).expect("mark hook executable");
|
||||
|
||||
Command::new("git")
|
||||
.args([
|
||||
"config",
|
||||
"core.hooksPath",
|
||||
hooks_dir.to_string_lossy().as_ref(),
|
||||
])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("configure hooks path");
|
||||
|
||||
fs::write(repo_path.join("test.txt"), "test content").expect("refresh tracked file");
|
||||
|
||||
assert_eq!(get_has_changes(&repo_path).await, Some(false));
|
||||
assert!(
|
||||
!marker_path.exists(),
|
||||
"metadata collection should not invoke configured hook directories"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_clean_repo() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
@@ -482,6 +527,20 @@ async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_git_repo_root_with_fs_detects_gitdir_pointer() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let proj = tmp.path().join("proj");
|
||||
let nested = proj.join("nested");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(proj.join(".git"), "gitdir: /tmp/fake-worktree\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await,
|
||||
Some(proj.abs())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
|
||||
+190
-85
@@ -5,8 +5,8 @@
|
||||
//! events, and owns helper hooks used by goal lifecycle behavior.
|
||||
|
||||
use crate::StateDbHandle;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::GoalContext;
|
||||
use crate::session::TurnInput;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::state::ActiveTurn;
|
||||
@@ -15,21 +15,22 @@ use crate::tasks::RegularTask;
|
||||
use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME;
|
||||
use anyhow::Context;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::GOAL_BLOCKED_METRIC;
|
||||
use codex_otel::GOAL_BUDGET_LIMITED_METRIC;
|
||||
use codex_otel::GOAL_COMPLETED_METRIC;
|
||||
use codex_otel::GOAL_CREATED_METRIC;
|
||||
use codex_otel::GOAL_DURATION_SECONDS_METRIC;
|
||||
use codex_otel::GOAL_RESUMED_METRIC;
|
||||
use codex_otel::GOAL_TOKEN_COUNT_METRIC;
|
||||
use codex_otel::GOAL_USAGE_LIMITED_METRIC;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ThreadGoal;
|
||||
use codex_protocol::protocol::ThreadGoalStatus;
|
||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::validate_thread_goal_objective;
|
||||
use codex_rollout::state_db::reconcile_rollout;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
@@ -153,7 +154,9 @@ pub(crate) enum GoalRuntimeEvent<'a> {
|
||||
MaybeContinueIfIdle,
|
||||
TaskAborted {
|
||||
turn_context: Option<&'a TurnContext>,
|
||||
reason: TurnAbortReason,
|
||||
},
|
||||
UsageLimitReached {
|
||||
turn_context: &'a TurnContext,
|
||||
},
|
||||
ExternalMutationStarting,
|
||||
ExternalSet {
|
||||
@@ -331,8 +334,8 @@ impl Session {
|
||||
/// starts capture the active goal and token baseline, tool completions
|
||||
/// account usage and may inject budget steering, completion accounting
|
||||
/// suppresses that steering, external mutations account best-effort before
|
||||
/// changing state, interrupts pause active goals, thread resumes restore
|
||||
/// runtime state for already-active goals, explicit maybe-continue events
|
||||
/// changing state, thread resumes restore runtime state for already-active
|
||||
/// goals, explicit maybe-continue events
|
||||
/// start idle goal continuation turns, and continuation turns with no counted
|
||||
/// autonomous activity suppress the next automatic continuation until
|
||||
/// user/tool/external activity resets it.
|
||||
@@ -384,12 +387,13 @@ impl Session {
|
||||
self.maybe_continue_goal_if_idle_runtime().await;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::TaskAborted {
|
||||
turn_context,
|
||||
reason,
|
||||
} => Box::pin(async move {
|
||||
self.handle_thread_goal_task_abort(turn_context, reason)
|
||||
.await;
|
||||
GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move {
|
||||
self.handle_thread_goal_task_abort(turn_context).await;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move {
|
||||
self.usage_limit_active_thread_goal_for_turn(turn_context)
|
||||
.await?;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move {
|
||||
@@ -422,6 +426,7 @@ impl Session {
|
||||
|
||||
let state_db = self.require_state_db_for_thread_goals().await?;
|
||||
state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
.map(|goal| goal.map(protocol_goal_from_state))
|
||||
@@ -452,20 +457,24 @@ impl Session {
|
||||
|
||||
self.account_thread_goal_wall_clock_usage(
|
||||
&state_db,
|
||||
codex_state::ThreadGoalAccountingMode::ActiveOnly,
|
||||
codex_state::GoalAccountingMode::ActiveOnly,
|
||||
TerminalMetricEmission::Emit,
|
||||
)
|
||||
.await?;
|
||||
let mut replacing_goal = false;
|
||||
let previous_status;
|
||||
let goal = if let Some(objective) = objective.as_deref() {
|
||||
let existing_goal = state_db.get_thread_goal(self.conversation_id).await?;
|
||||
let existing_goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?;
|
||||
previous_status = existing_goal.as_ref().map(|goal| goal.status);
|
||||
if let Some(existing_goal) = existing_goal.as_ref() {
|
||||
state_db
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
self.conversation_id,
|
||||
codex_state::ThreadGoalUpdate {
|
||||
codex_state::GoalUpdate {
|
||||
objective: Some(objective.to_string()),
|
||||
status: status.map(state_goal_status_from_protocol),
|
||||
token_budget,
|
||||
@@ -482,6 +491,7 @@ impl Session {
|
||||
} else {
|
||||
replacing_goal = true;
|
||||
state_db
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
self.conversation_id,
|
||||
objective,
|
||||
@@ -493,14 +503,18 @@ impl Session {
|
||||
.await?
|
||||
}
|
||||
} else {
|
||||
let existing_goal = state_db.get_thread_goal(self.conversation_id).await?;
|
||||
let existing_goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?;
|
||||
previous_status = existing_goal.as_ref().map(|goal| goal.status);
|
||||
let expected_goal_id = existing_goal.map(|goal| goal.goal_id);
|
||||
let status = status.map(state_goal_status_from_protocol);
|
||||
state_db
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
self.conversation_id,
|
||||
codex_state::ThreadGoalUpdate {
|
||||
codex_state::GoalUpdate {
|
||||
objective: None,
|
||||
status,
|
||||
token_budget,
|
||||
@@ -516,6 +530,14 @@ impl Session {
|
||||
})?
|
||||
};
|
||||
|
||||
if objective.is_some() {
|
||||
set_thread_preview_from_goal_objective(
|
||||
&state_db,
|
||||
self.conversation_id,
|
||||
goal.objective.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let goal_status = goal.status;
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let previous_status_for_goal = if replacing_goal {
|
||||
@@ -526,6 +548,7 @@ impl Session {
|
||||
if replacing_goal {
|
||||
self.emit_goal_created_metric();
|
||||
}
|
||||
self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status);
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal);
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
@@ -576,11 +599,12 @@ impl Session {
|
||||
let state_db = self.require_state_db_for_thread_goals().await?;
|
||||
self.account_thread_goal_wall_clock_usage(
|
||||
&state_db,
|
||||
codex_state::ThreadGoalAccountingMode::ActiveOnly,
|
||||
codex_state::GoalAccountingMode::ActiveOnly,
|
||||
TerminalMetricEmission::Emit,
|
||||
)
|
||||
.await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.insert_thread_goal(
|
||||
self.conversation_id,
|
||||
objective,
|
||||
@@ -595,6 +619,12 @@ impl Session {
|
||||
)
|
||||
})?;
|
||||
|
||||
set_thread_preview_from_goal_objective(
|
||||
&state_db,
|
||||
self.conversation_id,
|
||||
goal.objective.as_str(),
|
||||
)
|
||||
.await;
|
||||
let goal_id = goal.goal_id.clone();
|
||||
self.emit_goal_created_metric();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
@@ -641,6 +671,7 @@ impl Session {
|
||||
let previous_status = previous_goal
|
||||
.as_ref()
|
||||
.and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status));
|
||||
self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status);
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
|
||||
let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone()));
|
||||
let goal_id = goal.goal_id;
|
||||
@@ -669,7 +700,10 @@ impl Session {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
}
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::Complete => {
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
}
|
||||
}
|
||||
@@ -723,6 +757,31 @@ impl Session {
|
||||
.counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]);
|
||||
}
|
||||
|
||||
fn emit_goal_resumed_metric(&self) {
|
||||
self.services
|
||||
.session_telemetry
|
||||
.counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]);
|
||||
}
|
||||
|
||||
fn emit_goal_resumed_metric_if_status_changed(
|
||||
&self,
|
||||
previous_status: Option<codex_state::ThreadGoalStatus>,
|
||||
goal_status: codex_state::ThreadGoalStatus,
|
||||
) {
|
||||
if goal_status == codex_state::ThreadGoalStatus::Active
|
||||
&& matches!(
|
||||
previous_status,
|
||||
Some(
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
)
|
||||
)
|
||||
{
|
||||
self.emit_goal_resumed_metric();
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_goal_terminal_metrics_if_status_changed(
|
||||
&self,
|
||||
previous_status: Option<codex_state::ThreadGoalStatus>,
|
||||
@@ -733,6 +792,8 @@ impl Session {
|
||||
}
|
||||
|
||||
let counter = match goal.status {
|
||||
codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC,
|
||||
codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC,
|
||||
codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC,
|
||||
codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC,
|
||||
codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => {
|
||||
@@ -760,7 +821,10 @@ impl Session {
|
||||
state_db: &StateDbHandle,
|
||||
expected_goal_id: Option<&str>,
|
||||
) -> anyhow::Result<Option<codex_state::ThreadGoalStatus>> {
|
||||
let goal = state_db.get_thread_goal(self.conversation_id).await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?;
|
||||
Ok(goal.and_then(|goal| {
|
||||
expected_goal_id
|
||||
.is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id)
|
||||
@@ -772,7 +836,7 @@ impl Session {
|
||||
let active = self.active_turn.lock().await;
|
||||
active
|
||||
.as_ref()
|
||||
.and_then(|active_turn| active_turn.tasks.values().next())
|
||||
.and_then(|active_turn| active_turn.task.as_ref())
|
||||
.map(|task| Arc::clone(&task.turn_context))
|
||||
}
|
||||
|
||||
@@ -801,7 +865,11 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
};
|
||||
match state_db.get_thread_goal(self.conversation_id).await {
|
||||
match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal))
|
||||
if matches!(
|
||||
goal.status,
|
||||
@@ -848,7 +916,7 @@ impl Session {
|
||||
async fn clear_reserved_goal_continuation_turn(&self, turn_state: &Arc<Mutex<TurnState>>) {
|
||||
let mut active_turn_guard = self.active_turn.lock().await;
|
||||
if let Some(active_turn) = active_turn_guard.as_ref()
|
||||
&& active_turn.tasks.is_empty()
|
||||
&& active_turn.task.is_none()
|
||||
&& Arc::ptr_eq(&active_turn.turn_state, turn_state)
|
||||
{
|
||||
*active_turn_guard = None;
|
||||
@@ -886,11 +954,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_thread_goal_task_abort(
|
||||
&self,
|
||||
turn_context: Option<&TurnContext>,
|
||||
reason: TurnAbortReason,
|
||||
) {
|
||||
async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) {
|
||||
if let Some(turn_context) = turn_context {
|
||||
self.take_thread_goal_continuation_turn(&turn_context.sub_id)
|
||||
.await;
|
||||
@@ -913,12 +977,6 @@ impl Session {
|
||||
accounting.turn = None;
|
||||
}
|
||||
}
|
||||
|
||||
if reason == TurnAbortReason::Interrupted
|
||||
&& let Err(err) = self.pause_active_thread_goal_for_interrupt().await
|
||||
{
|
||||
tracing::warn!("failed to pause active thread goal after interrupt: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn account_thread_goal_progress(
|
||||
@@ -963,11 +1021,12 @@ impl Session {
|
||||
.current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref())
|
||||
.await?;
|
||||
let outcome = state_db
|
||||
.thread_goals()
|
||||
.account_thread_goal_usage(
|
||||
self.conversation_id,
|
||||
time_delta_seconds,
|
||||
token_delta,
|
||||
codex_state::ThreadGoalAccountingMode::ActiveOnly,
|
||||
codex_state::GoalAccountingMode::ActiveOnly,
|
||||
expected_goal_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -978,13 +1037,15 @@ impl Session {
|
||||
.is_some_and(|goal_id| reported_goal_id.as_deref() == Some(goal_id))
|
||||
};
|
||||
let goal = match outcome {
|
||||
codex_state::ThreadGoalAccountingOutcome::Updated(goal) => {
|
||||
codex_state::GoalAccountingOutcome::Updated(goal) => {
|
||||
let clear_active_goal = match goal.status {
|
||||
codex_state::ThreadGoalStatus::Active => false,
|
||||
codex_state::ThreadGoalStatus::BudgetLimited => {
|
||||
matches!(budget_limit_steering, BudgetLimitSteering::Suppressed)
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => true,
|
||||
};
|
||||
{
|
||||
@@ -1009,7 +1070,7 @@ impl Session {
|
||||
}
|
||||
goal
|
||||
}
|
||||
codex_state::ThreadGoalAccountingOutcome::Unchanged(_) => return Ok(()),
|
||||
codex_state::GoalAccountingOutcome::Unchanged(_) => return Ok(()),
|
||||
};
|
||||
let should_steer_budget_limit =
|
||||
matches!(budget_limit_steering, BudgetLimitSteering::Allowed)
|
||||
@@ -1056,7 +1117,7 @@ impl Session {
|
||||
};
|
||||
self.account_thread_goal_wall_clock_usage(
|
||||
&state_db,
|
||||
codex_state::ThreadGoalAccountingMode::ActiveOnly,
|
||||
codex_state::GoalAccountingMode::ActiveOnly,
|
||||
TerminalMetricEmission::Suppress,
|
||||
)
|
||||
.await?;
|
||||
@@ -1066,7 +1127,7 @@ impl Session {
|
||||
async fn account_thread_goal_wall_clock_usage(
|
||||
&self,
|
||||
state_db: &StateDbHandle,
|
||||
mode: codex_state::ThreadGoalAccountingMode,
|
||||
mode: codex_state::GoalAccountingMode,
|
||||
terminal_metric_emission: TerminalMetricEmission,
|
||||
) -> anyhow::Result<Option<ThreadGoal>> {
|
||||
let _accounting_permit = self.goal_runtime.accounting_permit().await?;
|
||||
@@ -1085,6 +1146,7 @@ impl Session {
|
||||
.await?;
|
||||
|
||||
match state_db
|
||||
.thread_goals()
|
||||
.account_thread_goal_usage(
|
||||
self.conversation_id,
|
||||
time_delta_seconds,
|
||||
@@ -1094,7 +1156,7 @@ impl Session {
|
||||
)
|
||||
.await?
|
||||
{
|
||||
codex_state::ThreadGoalAccountingOutcome::Updated(goal) => {
|
||||
codex_state::GoalAccountingOutcome::Updated(goal) => {
|
||||
if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) {
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
|
||||
}
|
||||
@@ -1107,7 +1169,7 @@ impl Session {
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
Ok(Some(goal))
|
||||
}
|
||||
codex_state::ThreadGoalAccountingOutcome::Unchanged(goal) => {
|
||||
codex_state::GoalAccountingOutcome::Unchanged(goal) => {
|
||||
{
|
||||
let mut accounting = self.goal_runtime.accounting.lock().await;
|
||||
accounting.wall_clock.reset_baseline();
|
||||
@@ -1122,8 +1184,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn pause_active_thread_goal_for_interrupt(&self) -> anyhow::Result<()> {
|
||||
if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) {
|
||||
async fn usage_limit_active_thread_goal_for_turn(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
) -> anyhow::Result<()> {
|
||||
if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1140,34 +1205,34 @@ impl Session {
|
||||
let Some(state_db) = self.state_db_for_thread_goals().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
self.account_thread_goal_wall_clock_usage(
|
||||
&state_db,
|
||||
codex_state::ThreadGoalAccountingMode::ActiveStatusOnly,
|
||||
self.account_thread_goal_progress(
|
||||
turn_context,
|
||||
BudgetLimitSteering::Suppressed,
|
||||
TerminalMetricEmission::Emit,
|
||||
)
|
||||
.await?;
|
||||
let previous_status = self
|
||||
.current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None)
|
||||
.await?;
|
||||
let Some(goal) = state_db
|
||||
.pause_active_thread_goal(self.conversation_id)
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(self.conversation_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
self.goal_runtime
|
||||
.accounting
|
||||
.lock()
|
||||
.await
|
||||
.wall_clock
|
||||
.clear_active_goal();
|
||||
self.send_event_raw(Event {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
self.clear_active_goal_accounting(turn_context).await;
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
turn_id: None,
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
goal,
|
||||
}),
|
||||
})
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1192,7 +1257,11 @@ impl Session {
|
||||
let Some(state_db) = self.state_db_for_thread_goals().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(goal) = state_db.get_thread_goal(self.conversation_id).await? else {
|
||||
let Some(goal) = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?
|
||||
else {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1204,8 +1273,11 @@ impl Session {
|
||||
.await
|
||||
.wall_clock
|
||||
.mark_active_goal(goal.goal_id);
|
||||
self.emit_goal_resumed_metric();
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::BudgetLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
@@ -1237,7 +1309,11 @@ impl Session {
|
||||
Arc::clone(&active_turn.turn_state)
|
||||
};
|
||||
let goal_is_current = match self.state_db_for_thread_goals().await {
|
||||
Ok(Some(state_db)) => match state_db.get_thread_goal(self.conversation_id).await {
|
||||
Ok(Some(state_db)) => match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal))
|
||||
if goal.goal_id == candidate.goal_id
|
||||
&& goal.status == codex_state::ThreadGoalStatus::Active =>
|
||||
@@ -1269,12 +1345,16 @@ impl Session {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
for item in candidate.items {
|
||||
turn_state.push_pending_input(item);
|
||||
}
|
||||
}
|
||||
self.input_queue
|
||||
.extend_pending_input_for_turn_state(
|
||||
turn_state.as_ref(),
|
||||
candidate
|
||||
.items
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseInputItem)
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let turn_context = self
|
||||
.new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string())
|
||||
@@ -1284,7 +1364,7 @@ impl Session {
|
||||
let still_reserved = {
|
||||
let active_turn = self.active_turn.lock().await;
|
||||
active_turn.as_ref().is_some_and(|active_turn| {
|
||||
active_turn.tasks.is_empty() && Arc::ptr_eq(&active_turn.turn_state, &turn_state)
|
||||
active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, &turn_state)
|
||||
})
|
||||
};
|
||||
if !still_reserved {
|
||||
@@ -1312,11 +1392,15 @@ impl Session {
|
||||
tracing::debug!("skipping active goal continuation because a turn is already active");
|
||||
return None;
|
||||
}
|
||||
if self.has_queued_response_items_for_next_turn().await {
|
||||
if self
|
||||
.input_queue
|
||||
.has_queued_response_items_for_next_turn()
|
||||
.await
|
||||
{
|
||||
tracing::debug!("skipping active goal continuation because queued input exists");
|
||||
return None;
|
||||
}
|
||||
if self.has_trigger_turn_mailbox_items().await {
|
||||
if self.input_queue.has_trigger_turn_mailbox_items().await {
|
||||
tracing::debug!(
|
||||
"skipping active goal continuation because trigger-turn mailbox input is pending"
|
||||
);
|
||||
@@ -1333,7 +1417,11 @@ impl Session {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let goal = match state_db.get_thread_goal(self.conversation_id).await {
|
||||
let goal = match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal)) => goal,
|
||||
Ok(None) => {
|
||||
tracing::debug!("skipping active goal continuation because no goal is set");
|
||||
@@ -1349,8 +1437,11 @@ impl Session {
|
||||
return None;
|
||||
}
|
||||
if self.active_turn.lock().await.is_some()
|
||||
|| self.has_queued_response_items_for_next_turn().await
|
||||
|| self.has_trigger_turn_mailbox_items().await
|
||||
|| self
|
||||
.input_queue
|
||||
.has_queued_response_items_for_next_turn()
|
||||
.await
|
||||
|| self.input_queue.has_trigger_turn_mailbox_items().await
|
||||
{
|
||||
tracing::debug!("skipping active goal continuation because pending work appeared");
|
||||
return None;
|
||||
@@ -1438,6 +1529,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_thread_preview_from_goal_objective(
|
||||
state_db: &StateDbHandle,
|
||||
thread_id: ThreadId,
|
||||
objective: &str,
|
||||
) {
|
||||
if let Err(err) = state_db
|
||||
.set_thread_preview_if_empty(thread_id, objective)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"failed to set empty thread preview from goal objective for {thread_id}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_ignore_goal_for_mode(mode: ModeKind) -> bool {
|
||||
mode == ModeKind::Plan
|
||||
}
|
||||
@@ -1524,14 +1630,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
|
||||
}
|
||||
|
||||
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
|
||||
let context = GoalContext { prompt };
|
||||
ResponseInputItem::Message {
|
||||
role: <GoalContext as ContextualUserFragment>::ROLE.to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: context.render(),
|
||||
}],
|
||||
phase: None,
|
||||
}
|
||||
GoalContext::new(prompt).into_response_input_item()
|
||||
}
|
||||
|
||||
pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal {
|
||||
@@ -1553,6 +1652,8 @@ pub(crate) fn protocol_goal_status_from_state(
|
||||
match status {
|
||||
codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active,
|
||||
codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
|
||||
codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
|
||||
codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
|
||||
codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
|
||||
codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
|
||||
}
|
||||
@@ -1564,6 +1665,8 @@ pub(crate) fn state_goal_status_from_protocol(
|
||||
match status {
|
||||
ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active,
|
||||
ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused,
|
||||
ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked,
|
||||
ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited,
|
||||
ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited,
|
||||
ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete,
|
||||
}
|
||||
@@ -1642,7 +1745,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_prompt_only_tells_model_to_update_goal_when_complete() {
|
||||
fn continuation_prompt_allows_complete_and_strict_blocked_updates() {
|
||||
let prompt = continuation_prompt(&ThreadGoal {
|
||||
thread_id: ThreadId::new(),
|
||||
objective: "finish the stack".to_string(),
|
||||
@@ -1659,9 +1762,11 @@ mod tests {
|
||||
assert!(prompt.contains("<objective>\nfinish the stack\n</objective>"));
|
||||
assert!(prompt.contains("Token budget: 10000"));
|
||||
assert!(prompt.contains("call update_goal with status \"complete\""));
|
||||
assert!(!prompt.contains(
|
||||
"explain the blocker or next required input to the user and wait for new input"
|
||||
));
|
||||
assert!(prompt.contains("status \"blocked\""));
|
||||
assert!(prompt.contains("at least three consecutive goal turns"));
|
||||
assert!(prompt.contains("same blocking condition"));
|
||||
assert!(prompt.contains("original/user-triggered turn"));
|
||||
assert!(prompt.contains("truly at an impasse"));
|
||||
assert!(!prompt.contains("budgetLimited"));
|
||||
assert!(!prompt.contains("status \"paused\""));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use serde::Serialize;
|
||||
pub(crate) use approval_request::GuardianApprovalRequest;
|
||||
pub(crate) use approval_request::GuardianMcpAnnotations;
|
||||
pub(crate) use approval_request::GuardianNetworkAccessTrigger;
|
||||
#[cfg(test)]
|
||||
pub(crate) use approval_request::guardian_approval_request_to_json;
|
||||
pub(crate) use review::guardian_rejection_message;
|
||||
pub(crate) use review::guardian_timeout_message;
|
||||
|
||||
@@ -635,7 +635,8 @@ pub(super) async fn run_guardian_review_session(
|
||||
schema: serde_json::Value,
|
||||
external_cancel: Option<CancellationToken>,
|
||||
) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) {
|
||||
let live_network_config = match session.services.network_proxy.as_ref() {
|
||||
let network_proxy = session.services.network_proxy.load_full();
|
||||
let live_network_config = match network_proxy.as_ref() {
|
||||
Some(network_proxy) => match network_proxy.proxy().current_cfg().await {
|
||||
Ok(config) => Some(config),
|
||||
Err(err) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -13,12 +13,16 @@ use codex_hooks::PostToolUseRequest;
|
||||
use codex_hooks::PreToolUseOutcome;
|
||||
use codex_hooks::PreToolUseRequest;
|
||||
use codex_hooks::SessionStartOutcome;
|
||||
use codex_hooks::StartHookTarget;
|
||||
use codex_hooks::StopHookTarget;
|
||||
use codex_hooks::StopOutcome;
|
||||
use codex_hooks::SubagentHookContext;
|
||||
use codex_hooks::UserPromptSubmitOutcome;
|
||||
use codex_hooks::UserPromptSubmitRequest;
|
||||
use codex_otel::HOOK_RUN_DURATION_METRIC;
|
||||
use codex_otel::HOOK_RUN_METRIC;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -28,12 +32,15 @@ use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookStartedEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_thread_store::ReadThreadParams;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::HookAdditionalContext;
|
||||
use crate::event_mapping::parse_turn_item;
|
||||
use crate::session::TurnInput;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
@@ -49,22 +56,6 @@ pub(crate) enum PreToolUseHookResult {
|
||||
Blocked(String),
|
||||
}
|
||||
|
||||
pub(crate) enum PendingInputHookDisposition {
|
||||
Accepted(Box<PendingInputRecord>),
|
||||
Blocked { additional_contexts: Vec<String> },
|
||||
}
|
||||
|
||||
pub(crate) enum PendingInputRecord {
|
||||
UserMessage {
|
||||
content: Vec<UserInput>,
|
||||
response_item: ResponseItem,
|
||||
additional_contexts: Vec<String>,
|
||||
},
|
||||
ConversationItem {
|
||||
response_item: ResponseItem,
|
||||
},
|
||||
}
|
||||
|
||||
struct ContextInjectingHookOutcome {
|
||||
hook_events: Vec<HookCompletedEvent>,
|
||||
outcome: HookRuntimeOutcome,
|
||||
@@ -110,30 +101,55 @@ pub(crate) async fn run_pending_session_start_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
) -> bool {
|
||||
let Some(session_start_source) = sess.take_pending_session_start_source().await else {
|
||||
return false;
|
||||
};
|
||||
while let Some(session_start_source) = sess.take_pending_session_start_source().await {
|
||||
// Pending session-start hooks are reused to dispatch thread-spawn subagent
|
||||
// starts. Other subagent sessions are internal/system work and do not run
|
||||
// start hooks.
|
||||
let target = match &turn_context.session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. })
|
||||
if matches!(
|
||||
session_start_source,
|
||||
codex_hooks::SessionStartSource::Startup
|
||||
) =>
|
||||
{
|
||||
let context = subagent_hook_context(sess, agent_role);
|
||||
StartHookTarget::SubagentStart {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
agent_id: context.agent_id,
|
||||
agent_type: context.agent_type,
|
||||
}
|
||||
}
|
||||
SessionSource::SubAgent(_) => return false,
|
||||
_ => StartHookTarget::SessionStart {
|
||||
source: session_start_source,
|
||||
},
|
||||
};
|
||||
let request = codex_hooks::SessionStartRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
target,
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let preview_runs = hooks.preview_session_start(&request);
|
||||
if run_context_injecting_hook(
|
||||
sess,
|
||||
turn_context,
|
||||
preview_runs,
|
||||
hooks.run_session_start(request, Some(turn_context.sub_id.clone())),
|
||||
)
|
||||
.await
|
||||
.record_additional_contexts(sess, turn_context)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let request = codex_hooks::SessionStartRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
source: session_start_source,
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let preview_runs = hooks.preview_session_start(&request);
|
||||
run_context_injecting_hook(
|
||||
sess,
|
||||
turn_context,
|
||||
preview_runs,
|
||||
hooks.run_session_start(request, Some(turn_context.sub_id.clone())),
|
||||
)
|
||||
.await
|
||||
.record_additional_contexts(sess, turn_context)
|
||||
.await
|
||||
false
|
||||
}
|
||||
|
||||
/// Runs matching `PreToolUse` hooks before a tool executes.
|
||||
@@ -151,6 +167,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
|
||||
let request = PreToolUseRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
@@ -211,6 +228,7 @@ pub(crate) async fn run_permission_request_hooks(
|
||||
let request = PermissionRequestRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
@@ -252,6 +270,7 @@ pub(crate) async fn run_post_tool_use_hooks(
|
||||
let request = PostToolUseRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
@@ -272,6 +291,76 @@ pub(crate) async fn run_post_tool_use_hooks(
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn run_turn_stop_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
stop_hook_active: bool,
|
||||
last_assistant_message: Option<String>,
|
||||
) -> StopOutcome {
|
||||
// Resolve the stop hook kind from the session source before building the
|
||||
// request. Root turns run Stop; thread-spawned child turns run SubagentStop.
|
||||
let (target, transcript_path) = match &turn_context.session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
agent_role,
|
||||
parent_thread_id,
|
||||
..
|
||||
}) => {
|
||||
let context = subagent_hook_context(sess, agent_role);
|
||||
let agent_transcript_path = sess.hook_transcript_path().await;
|
||||
let parent_transcript_path = match sess
|
||||
.services
|
||||
.thread_store
|
||||
.read_thread(ReadThreadParams {
|
||||
thread_id: *parent_thread_id,
|
||||
include_archived: true,
|
||||
include_history: false,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(thread) => thread.rollout_path,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
parent_thread_id = %parent_thread_id,
|
||||
error = %error,
|
||||
"failed to resolve parent transcript path for subagent hook"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
(
|
||||
StopHookTarget::SubagentStop {
|
||||
agent_id: context.agent_id,
|
||||
agent_type: context.agent_type,
|
||||
agent_transcript_path,
|
||||
},
|
||||
parent_transcript_path,
|
||||
)
|
||||
}
|
||||
// Internal/synthetic subagents do not expose user-configured lifecycle
|
||||
// hooks, so there is no Stop or SubagentStop request to dispatch.
|
||||
SessionSource::SubAgent(_) => return StopOutcome::default(),
|
||||
_ => (StopHookTarget::Stop, sess.hook_transcript_path().await),
|
||||
};
|
||||
let request = codex_hooks::StopRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
stop_hook_active,
|
||||
last_assistant_message,
|
||||
target,
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await;
|
||||
|
||||
let mut outcome = hooks.run_stop(request).await;
|
||||
emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await;
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_compact_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -280,6 +369,7 @@ pub(crate) async fn run_pre_compact_hooks(
|
||||
let request = codex_hooks::PreCompactRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
@@ -318,6 +408,7 @@ pub(crate) async fn run_post_compact_hooks(
|
||||
let request = codex_hooks::PostCompactRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
@@ -336,83 +427,126 @@ pub(crate) async fn run_post_compact_hooks(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_user_prompt_submit_hooks(
|
||||
pub(crate) async fn run_legacy_after_agent_hook(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
prompt: String,
|
||||
) -> HookRuntimeOutcome {
|
||||
let request = UserPromptSubmitRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
prompt,
|
||||
};
|
||||
input: &[ResponseItem],
|
||||
last_assistant_message: Option<String>,
|
||||
) -> bool {
|
||||
let mut abort_message = None;
|
||||
let input_messages = input
|
||||
.iter()
|
||||
.filter_map(|item| match parse_turn_item(item) {
|
||||
Some(TurnItem::UserMessage(user_message)) => Some(user_message.message()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let hooks = sess.hooks();
|
||||
let preview_runs = hooks.preview_user_prompt_submit(&request);
|
||||
run_context_injecting_hook(
|
||||
sess,
|
||||
turn_context,
|
||||
preview_runs,
|
||||
hooks.run_user_prompt_submit(request),
|
||||
)
|
||||
.await
|
||||
for hook_outcome in hooks
|
||||
.dispatch(codex_hooks::HookPayload {
|
||||
session_id: sess.session_id().into(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: codex_hooks::HookEvent::AfterAgent {
|
||||
event: codex_hooks::HookEventAfterAgent {
|
||||
thread_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
input_messages,
|
||||
last_assistant_message,
|
||||
},
|
||||
},
|
||||
})
|
||||
.await
|
||||
{
|
||||
let hook_name = hook_outcome.hook_name;
|
||||
let (error, should_abort) = match hook_outcome.result {
|
||||
codex_hooks::HookResult::Success => continue,
|
||||
codex_hooks::HookResult::FailedContinue(error) => (error, false),
|
||||
codex_hooks::HookResult::FailedAbort(error) => (error, true),
|
||||
};
|
||||
let action = if should_abort {
|
||||
"aborting operation"
|
||||
} else {
|
||||
"continuing"
|
||||
};
|
||||
tracing::warn!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"after_agent hook failed; {action}"
|
||||
);
|
||||
if should_abort && abort_message.is_none() {
|
||||
abort_message = Some(format!(
|
||||
"after_agent hook '{hook_name}' failed and aborted turn completion: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let Some(message) = abort_message else {
|
||||
return false;
|
||||
};
|
||||
let event = EventMsg::Error(codex_protocol::protocol::ErrorEvent {
|
||||
message,
|
||||
codex_error_info: None,
|
||||
});
|
||||
sess.send_event(turn_context, event).await;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn inspect_pending_input(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
pending_input_item: ResponseInputItem,
|
||||
) -> PendingInputHookDisposition {
|
||||
let response_item = ResponseItem::from(pending_input_item);
|
||||
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
|
||||
let user_prompt_submit_outcome =
|
||||
run_user_prompt_submit_hooks(sess, turn_context, user_message.message()).await;
|
||||
if user_prompt_submit_outcome.should_stop {
|
||||
PendingInputHookDisposition::Blocked {
|
||||
additional_contexts: user_prompt_submit_outcome.additional_contexts,
|
||||
}
|
||||
} else {
|
||||
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage {
|
||||
content: user_message.content,
|
||||
response_item,
|
||||
additional_contexts: user_prompt_submit_outcome.additional_contexts,
|
||||
}))
|
||||
pending_input_item: &TurnInput,
|
||||
) -> HookRuntimeOutcome {
|
||||
match pending_input_item {
|
||||
TurnInput::UserInput(content) => {
|
||||
let request = UserPromptSubmitRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
prompt: UserMessageItem::new(content).message(),
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let preview_runs = hooks.preview_user_prompt_submit(&request);
|
||||
run_context_injecting_hook(
|
||||
sess,
|
||||
turn_context,
|
||||
preview_runs,
|
||||
hooks.run_user_prompt_submit(request),
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::ConversationItem {
|
||||
response_item,
|
||||
}))
|
||||
TurnInput::ResponseInputItem(_) => HookRuntimeOutcome {
|
||||
should_stop: false,
|
||||
additional_contexts: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_pending_input(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
pending_input: PendingInputRecord,
|
||||
pending_input: TurnInput,
|
||||
additional_contexts: Vec<String>,
|
||||
) {
|
||||
match pending_input {
|
||||
PendingInputRecord::UserMessage {
|
||||
content,
|
||||
response_item,
|
||||
additional_contexts,
|
||||
} => {
|
||||
sess.record_user_prompt_and_emit_turn_item(
|
||||
turn_context.as_ref(),
|
||||
content.as_slice(),
|
||||
response_item,
|
||||
)
|
||||
.await;
|
||||
record_additional_contexts(sess, turn_context, additional_contexts).await;
|
||||
TurnInput::UserInput(content) => {
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), content.as_slice())
|
||||
.await;
|
||||
}
|
||||
PendingInputRecord::ConversationItem { response_item } => {
|
||||
TurnInput::ResponseInputItem(input) => {
|
||||
let response_item = ResponseItem::from(input);
|
||||
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
record_additional_contexts(sess, turn_context, additional_contexts).await;
|
||||
}
|
||||
|
||||
async fn run_context_injecting_hook<Fut, Outcome>(
|
||||
@@ -555,6 +689,8 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEventName::SubagentStart => "SubagentStart",
|
||||
HookEventName::SubagentStop => "SubagentStop",
|
||||
HookEventName::Stop => "Stop",
|
||||
};
|
||||
let hook_source = match run.source {
|
||||
@@ -595,6 +731,27 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn thread_spawn_subagent_hook_context(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &TurnContext,
|
||||
) -> Option<SubagentHookContext> {
|
||||
match &turn_context.session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => {
|
||||
Some(subagent_hook_context(sess, agent_role))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn subagent_hook_context(sess: &Arc<Session>, agent_role: &Option<String>) -> SubagentHookContext {
|
||||
SubagentHookContext {
|
||||
agent_id: sess.thread_id().to_string(),
|
||||
agent_type: agent_role
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
|
||||
match value {
|
||||
CompactionTrigger::Manual => "manual",
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
mod arc_monitor;
|
||||
mod client;
|
||||
mod client_common;
|
||||
mod realtime_context;
|
||||
mod realtime_conversation;
|
||||
mod realtime_prompt;
|
||||
mod responses_retry;
|
||||
pub(crate) mod session;
|
||||
pub use session::SteerInputError;
|
||||
mod codex_thread;
|
||||
@@ -21,7 +21,7 @@ mod compact_remote_v2;
|
||||
mod config_lock;
|
||||
mod credentialed_routes;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadTurnContextOverrides;
|
||||
pub use codex_thread::CodexThreadSettingsOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
pub use session::turn_context::TurnContext;
|
||||
mod agent;
|
||||
@@ -91,13 +91,11 @@ pub(crate) use skills::SkillsManager;
|
||||
pub(crate) use skills::build_available_skills;
|
||||
pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
pub(crate) use skills::collect_env_var_dependencies;
|
||||
pub(crate) use skills::collect_explicit_skill_mentions;
|
||||
pub(crate) use skills::default_skill_metadata_budget;
|
||||
pub(crate) use skills::injection;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
|
||||
@@ -361,7 +361,7 @@ fn mcp_dependency_to_server_config(
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -391,7 +391,7 @@ fn mcp_dependency_to_server_config(
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
|
||||
@@ -3,22 +3,12 @@ use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::McpElicitationObjectType;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use tracing::error;
|
||||
|
||||
use crate::arc_monitor::ArcMonitorOutcome;
|
||||
use crate::arc_monitor::monitor_action;
|
||||
use crate::config::Config;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::connectors;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::GuardianMcpAnnotations;
|
||||
use crate::guardian::guardian_approval_request_to_json;
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
use crate::guardian::guardian_timeout_message;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
@@ -36,6 +26,11 @@ use crate::turn_metadata::McpTurnMetadataContext;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::McpElicitationObjectType;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_features::Feature;
|
||||
use codex_hooks::PermissionRequestDecision;
|
||||
@@ -91,6 +86,7 @@ use std::sync::Arc;
|
||||
use toml_edit::value;
|
||||
use tracing::Instrument;
|
||||
use tracing::Span;
|
||||
use tracing::error;
|
||||
use tracing::field::Empty;
|
||||
use url::Url;
|
||||
|
||||
@@ -114,7 +110,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
call_id: String,
|
||||
server: String,
|
||||
tool_name: String,
|
||||
hook_tool_name: String,
|
||||
hook_tool_name: HookToolName,
|
||||
arguments: String,
|
||||
) -> HandledMcpToolCall {
|
||||
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
|
||||
@@ -142,9 +138,14 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
|
||||
let metadata =
|
||||
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
|
||||
let mcp_app_resource_uri = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.mcp_app_resource_uri.clone());
|
||||
let item_metadata = McpToolCallItemMetadata {
|
||||
mcp_app_resource_uri: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.mcp_app_resource_uri.clone()),
|
||||
plugin_id: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.plugin_id.clone()),
|
||||
};
|
||||
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
connectors::app_tool_policy(
|
||||
&turn_context.config,
|
||||
@@ -175,7 +176,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
item_metadata.clone(),
|
||||
"MCP tool call blocked by app configuration".to_string(),
|
||||
/*already_started*/ false,
|
||||
)
|
||||
@@ -204,7 +205,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation.clone(),
|
||||
mcp_app_resource_uri.clone(),
|
||||
item_metadata.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -229,7 +230,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
&call_id,
|
||||
invocation,
|
||||
metadata.as_ref(),
|
||||
mcp_app_resource_uri,
|
||||
item_metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -240,7 +241,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
item_metadata.clone(),
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
@@ -253,19 +254,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
McpToolApprovalDecision::BlockedBySafetyMonitor(message) => {
|
||||
notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
item_metadata.clone(),
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
@@ -296,7 +285,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
&call_id,
|
||||
invocation,
|
||||
metadata.as_ref(),
|
||||
mcp_app_resource_uri,
|
||||
item_metadata,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -306,13 +295,19 @@ pub(crate) struct HandledMcpToolCall {
|
||||
pub(crate) tool_input: JsonValue,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct McpToolCallItemMetadata {
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
plugin_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn handle_approved_mcp_tool_call(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
call_id: &str,
|
||||
invocation: McpInvocation,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
item_metadata: McpToolCallItemMetadata,
|
||||
) -> HandledMcpToolCall {
|
||||
let server = invocation.server.clone();
|
||||
maybe_mark_thread_memory_mode_polluted(sess, turn_context, &server).await;
|
||||
@@ -381,7 +376,7 @@ async fn handle_approved_mcp_tool_call(
|
||||
turn_context,
|
||||
call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri,
|
||||
item_metadata,
|
||||
duration,
|
||||
truncate_mcp_tool_result_for_event(&result),
|
||||
)
|
||||
@@ -661,7 +656,8 @@ async fn maybe_request_codex_apps_auth_elicitation(
|
||||
};
|
||||
let response = sess
|
||||
.request_mcp_server_elicitation(turn_context, request_id, params)
|
||||
.await;
|
||||
.await
|
||||
.response;
|
||||
if !response
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept)
|
||||
@@ -855,7 +851,7 @@ async fn notify_mcp_tool_call_started(
|
||||
turn_context: &TurnContext,
|
||||
call_id: &str,
|
||||
invocation: McpInvocation,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
item_metadata: McpToolCallItemMetadata,
|
||||
) {
|
||||
let McpInvocation {
|
||||
server,
|
||||
@@ -867,7 +863,8 @@ async fn notify_mcp_tool_call_started(
|
||||
server,
|
||||
tool,
|
||||
arguments: arguments.unwrap_or(JsonValue::Null),
|
||||
mcp_app_resource_uri,
|
||||
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
|
||||
plugin_id: item_metadata.plugin_id,
|
||||
status: McpToolCallStatus::InProgress,
|
||||
result: None,
|
||||
error: None,
|
||||
@@ -881,7 +878,7 @@ async fn notify_mcp_tool_call_completed(
|
||||
turn_context: &TurnContext,
|
||||
call_id: &str,
|
||||
invocation: McpInvocation,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
item_metadata: McpToolCallItemMetadata,
|
||||
duration: Duration,
|
||||
result: Result<CallToolResult, String>,
|
||||
) {
|
||||
@@ -906,7 +903,8 @@ async fn notify_mcp_tool_call_completed(
|
||||
server,
|
||||
tool,
|
||||
arguments: arguments.unwrap_or(JsonValue::Null),
|
||||
mcp_app_resource_uri,
|
||||
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
|
||||
plugin_id: item_metadata.plugin_id,
|
||||
status,
|
||||
result,
|
||||
error,
|
||||
@@ -966,7 +964,6 @@ enum McpToolApprovalDecision {
|
||||
AcceptAndRemember,
|
||||
Decline { message: Option<String> },
|
||||
Cancel,
|
||||
BlockedBySafetyMonitor(String),
|
||||
}
|
||||
|
||||
pub(crate) struct McpToolApprovalMetadata {
|
||||
@@ -974,6 +971,7 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
connector_id: Option<String>,
|
||||
connector_name: Option<String>,
|
||||
connector_description: Option<String>,
|
||||
plugin_id: Option<String>,
|
||||
tool_title: Option<String>,
|
||||
tool_description: Option<String>,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
@@ -983,6 +981,7 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
|
||||
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
|
||||
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
|
||||
const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id";
|
||||
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
|
||||
|
||||
async fn custom_mcp_tool_approval_mode(
|
||||
@@ -1068,6 +1067,12 @@ fn build_mcp_tool_call_request_meta(
|
||||
serde_json::Value::Object(codex_apps_meta),
|
||||
);
|
||||
}
|
||||
if let Some(plugin_id) = metadata.and_then(|metadata| metadata.plugin_id.as_ref()) {
|
||||
request_meta.insert(
|
||||
MCP_TOOL_PLUGIN_ID_META_KEY.to_string(),
|
||||
serde_json::Value::String(plugin_id.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
(!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta))
|
||||
}
|
||||
@@ -1122,8 +1127,6 @@ pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this se
|
||||
pub(crate) const MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC: &str = "__codex_mcp_decline__";
|
||||
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again";
|
||||
const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow";
|
||||
|
||||
pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool {
|
||||
question_id
|
||||
@@ -1155,7 +1158,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
turn_context: &Arc<TurnContext>,
|
||||
call_id: &str,
|
||||
invocation: &McpInvocation,
|
||||
hook_tool_name: &str,
|
||||
hook_tool_name: &HookToolName,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> Option<McpToolApprovalDecision> {
|
||||
@@ -1176,31 +1179,6 @@ async fn maybe_request_mcp_tool_approval(
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut monitor_reason = None;
|
||||
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve;
|
||||
|
||||
if auto_approved_by_policy {
|
||||
match maybe_monitor_auto_approved_mcp_tool_call(
|
||||
sess,
|
||||
turn_context,
|
||||
invocation,
|
||||
metadata,
|
||||
approval_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ArcMonitorOutcome::Ok => return None,
|
||||
ArcMonitorOutcome::AskUser(reason) => {
|
||||
monitor_reason = Some(reason);
|
||||
}
|
||||
ArcMonitorOutcome::SteerModel(reason) => {
|
||||
return Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
arc_monitor_interrupt_message(&reason),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
|
||||
let persistent_approval_key =
|
||||
persistent_mcp_tool_approval_key(invocation, metadata, approval_mode);
|
||||
@@ -1215,7 +1193,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
turn_context,
|
||||
call_id,
|
||||
PermissionRequestPayload {
|
||||
tool_name: HookToolName::new(hook_tool_name),
|
||||
tool_name: hook_tool_name.clone(),
|
||||
tool_input: invocation
|
||||
.arguments
|
||||
.clone()
|
||||
@@ -1247,7 +1225,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
turn_context,
|
||||
review_id.clone(),
|
||||
build_guardian_mcp_tool_review_request(call_id, invocation, metadata),
|
||||
monitor_reason.clone(),
|
||||
/*retry_reason*/ None,
|
||||
)
|
||||
.await;
|
||||
let decision = mcp_tool_approval_decision_from_guardian(sess, &review_id, decision).await;
|
||||
@@ -1279,7 +1257,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.as_ref()
|
||||
.map(|rendered_template| rendered_template.tool_params_display.clone())
|
||||
.or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref()));
|
||||
let mut question = build_mcp_tool_approval_question(
|
||||
let question = build_mcp_tool_approval_question(
|
||||
question_id.clone(),
|
||||
&invocation.server,
|
||||
&invocation.tool,
|
||||
@@ -1289,8 +1267,6 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.as_ref()
|
||||
.map(|rendered_template| rendered_template.question.as_str()),
|
||||
);
|
||||
question.question =
|
||||
mcp_tool_approval_question_text(question.question, monitor_reason.as_deref());
|
||||
if tool_call_mcp_elicitation_enabled {
|
||||
let request_id = rmcp::model::RequestId::String(
|
||||
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
|
||||
@@ -1307,17 +1283,16 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.or(invocation.arguments.as_ref()),
|
||||
tool_params_display: tool_params_display.as_deref(),
|
||||
question,
|
||||
message_override: rendered_template.as_ref().and_then(|rendered_template| {
|
||||
monitor_reason
|
||||
.is_none()
|
||||
.then_some(rendered_template.elicitation_message.as_str())
|
||||
}),
|
||||
message_override: rendered_template
|
||||
.as_ref()
|
||||
.map(|rendered_template| rendered_template.elicitation_message.as_str()),
|
||||
prompt_options,
|
||||
},
|
||||
);
|
||||
let decision = parse_mcp_tool_approval_elicitation_response(
|
||||
sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params)
|
||||
.await,
|
||||
.await
|
||||
.response,
|
||||
&question_id,
|
||||
);
|
||||
let decision = normalize_approval_decision_for_mode(decision, approval_mode);
|
||||
@@ -1353,37 +1328,6 @@ async fn maybe_request_mcp_tool_approval(
|
||||
Some(decision)
|
||||
}
|
||||
|
||||
async fn maybe_monitor_auto_approved_mcp_tool_call(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
invocation: &McpInvocation,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> ArcMonitorOutcome {
|
||||
let action = prepare_arc_request_action(invocation, metadata);
|
||||
monitor_action(
|
||||
sess,
|
||||
turn_context,
|
||||
action,
|
||||
mcp_tool_approval_callsite_mode(approval_mode, turn_context),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn prepare_arc_request_action(
|
||||
invocation: &McpInvocation,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
) -> serde_json::Value {
|
||||
let request = build_guardian_mcp_tool_review_request("arc-monitor", invocation, metadata);
|
||||
match guardian_approval_request_to_json(&request) {
|
||||
Ok(action) => action,
|
||||
Err(error) => {
|
||||
error!(error = %error, "failed to serialize guardian MCP approval request for ARC");
|
||||
serde_json::Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn session_mcp_tool_approval_key(
|
||||
invocation: &McpInvocation,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
@@ -1458,18 +1402,6 @@ async fn mcp_tool_approval_decision_from_guardian(
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_tool_approval_callsite_mode(
|
||||
approval_mode: AppToolApproval,
|
||||
_turn_context: &TurnContext,
|
||||
) -> &'static str {
|
||||
match approval_mode {
|
||||
AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW,
|
||||
AppToolApproval::Auto | AppToolApproval::Prompt => {
|
||||
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP approval metadata reads through the session-owned manager guard"
|
||||
@@ -1480,13 +1412,11 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> Option<McpToolApprovalMetadata> {
|
||||
let tools = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let manager = sess.services.mcp_connection_manager.read().await;
|
||||
let plugin_id = manager
|
||||
.plugin_id_for_mcp_server_name(server)
|
||||
.map(str::to_string);
|
||||
let tools = manager.list_all_tools().await;
|
||||
let tool_info = tools
|
||||
.into_iter()
|
||||
.find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?;
|
||||
@@ -1519,6 +1449,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
connector_id: tool_info.connector_id,
|
||||
connector_name: tool_info.connector_name,
|
||||
connector_description,
|
||||
plugin_id,
|
||||
tool_title: tool_info.tool.title,
|
||||
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
|
||||
mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()),
|
||||
@@ -1660,24 +1591,6 @@ fn build_mcp_tool_approval_fallback_message(
|
||||
format!("Allow {actor} to run tool \"{tool_name}\"?")
|
||||
}
|
||||
|
||||
fn mcp_tool_approval_question_text(question: String, monitor_reason: Option<&str>) -> String {
|
||||
match monitor_reason.map(str::trim) {
|
||||
Some(reason) if !reason.is_empty() => {
|
||||
format!("Tool call needs your approval. Reason: {reason}")
|
||||
}
|
||||
_ => question,
|
||||
}
|
||||
}
|
||||
|
||||
fn arc_monitor_interrupt_message(reason: &str) -> String {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
"Tool call was cancelled because of safety risks.".to_string()
|
||||
} else {
|
||||
format!("Tool call was cancelled because of safety risks: {reason}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mcp_tool_approval_elicitation_request(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -1979,8 +1892,7 @@ async fn apply_mcp_tool_approval_decision(
|
||||
}
|
||||
McpToolApprovalDecision::Accept
|
||||
| McpToolApprovalDecision::Decline { .. }
|
||||
| McpToolApprovalDecision::Cancel
|
||||
| McpToolApprovalDecision::BlockedBySafetyMonitor(_) => {}
|
||||
| McpToolApprovalDecision::Cancel => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2193,7 +2105,7 @@ async fn notify_mcp_tool_call_skip(
|
||||
turn_context: &TurnContext,
|
||||
call_id: &str,
|
||||
invocation: McpInvocation,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
item_metadata: McpToolCallItemMetadata,
|
||||
message: String,
|
||||
already_started: bool,
|
||||
) -> Result<CallToolResult, String> {
|
||||
@@ -2203,7 +2115,7 @@ async fn notify_mcp_tool_call_skip(
|
||||
turn_context,
|
||||
call_id,
|
||||
invocation.clone(),
|
||||
mcp_app_resource_uri.clone(),
|
||||
item_metadata.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -2213,7 +2125,7 @@ async fn notify_mcp_tool_call_skip(
|
||||
turn_context,
|
||||
call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri,
|
||||
item_metadata,
|
||||
Duration::ZERO,
|
||||
truncate_mcp_tool_result_for_event(&Err(message.clone())),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::session::tests::make_session_and_context;
|
||||
use crate::session::tests::make_session_and_context_with_rx;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::test_support::models_manager_with_provider;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
use crate::turn_metadata::McpTurnMetadataContext;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
@@ -76,6 +77,7 @@ fn approval_metadata(
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_name.map(str::to_string),
|
||||
connector_description: connector_description.map(str::to_string),
|
||||
plugin_id: None,
|
||||
tool_title: tool_title.map(str::to_string),
|
||||
tool_description: tool_description.map(str::to_string),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -404,17 +406,6 @@ fn prompt_mode_does_not_allow_persistent_remember() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_question_text_prepends_safety_reason() {
|
||||
assert_eq!(
|
||||
mcp_tool_approval_question_text(
|
||||
"Allow this action?".to_string(),
|
||||
Some("This tool may contact an external system."),
|
||||
),
|
||||
"Tool call needs your approval. Reason: This tool may contact an external system."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_call_span_records_expected_fields() {
|
||||
let buffer: &'static std::sync::Mutex<Vec<u8>> =
|
||||
@@ -1099,6 +1090,63 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() {
|
||||
let (_, turn_context) = make_session_and_context().await;
|
||||
let expected_turn_metadata = turn_context
|
||||
.turn_metadata_state
|
||||
.current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))
|
||||
.expect("turn metadata");
|
||||
let mut metadata = approval_metadata(
|
||||
/*connector_id*/ None, /*connector_name*/ None,
|
||||
/*connector_description*/ None, /*tool_title*/ None,
|
||||
/*tool_description*/ None,
|
||||
);
|
||||
metadata.plugin_id = Some("sample@test".to_string());
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_tool_call_request_meta(&turn_context, "sample", "call-plugin", Some(&metadata),),
|
||||
Some(serde_json::json!({
|
||||
crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,
|
||||
MCP_TOOL_PLUGIN_ID_META_KEY: "sample@test",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_call_item_includes_plugin_id() {
|
||||
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
|
||||
notify_mcp_tool_call_started(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-plugin",
|
||||
McpInvocation {
|
||||
server: "sample".to_string(),
|
||||
tool: "echo".to_string(),
|
||||
arguments: None,
|
||||
},
|
||||
McpToolCallItemMetadata {
|
||||
mcp_app_resource_uri: None,
|
||||
plugin_id: Some("sample@test".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())
|
||||
.await
|
||||
.expect("tool call item timed out")
|
||||
.expect("tool call item event");
|
||||
let EventMsg::ItemStarted(item_started) = event.msg else {
|
||||
panic!("expected ItemStarted event");
|
||||
};
|
||||
let TurnItem::McpToolCall(item) = item_started.item else {
|
||||
panic!("expected MCP tool call item");
|
||||
};
|
||||
|
||||
assert_eq!(item.plugin_id.as_deref(), Some("sample@test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() {
|
||||
let (_, turn_context) = make_session_and_context().await;
|
||||
@@ -1111,6 +1159,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Create Event".to_string()),
|
||||
tool_description: Some("Create a calendar event.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -1207,11 +1256,6 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
|
||||
|
||||
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let environment = session
|
||||
.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| session.services.environment_manager.local_environment());
|
||||
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
|
||||
&HashMap::new(),
|
||||
turn_context.config.mcp_oauth_credentials_store_mode,
|
||||
@@ -1220,13 +1264,14 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
|
||||
turn_context.sub_id.clone(),
|
||||
session.get_tx_event(),
|
||||
turn_context.permission_profile(),
|
||||
codex_mcp::McpRuntimeEnvironment::new(environment, {
|
||||
codex_mcp::McpRuntimeContext::new(Arc::clone(&session.services.environment_manager), {
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.to_path_buf()
|
||||
}),
|
||||
turn_context.config.codex_home.to_path_buf(),
|
||||
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
|
||||
/*host_owned_codex_apps_enabled*/ true,
|
||||
turn_context.config.prefix_mcp_tool_names(),
|
||||
rmcp::model::ElicitationCapability::default(),
|
||||
codex_mcp::ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
@@ -1573,6 +1618,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: None,
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -1603,42 +1649,6 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_arc_request_action_serializes_mcp_tool_call_shape() {
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool: "browser_navigate".to_string(),
|
||||
arguments: Some(serde_json::json!({
|
||||
"url": "https://example.com",
|
||||
})),
|
||||
};
|
||||
|
||||
let action = prepare_arc_request_action(
|
||||
&invocation,
|
||||
Some(&approval_metadata(
|
||||
/*connector_id*/ None,
|
||||
Some("Playwright"),
|
||||
/*connector_description*/ None,
|
||||
Some("Navigate"),
|
||||
/*tool_description*/ None,
|
||||
)),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
serde_json::json!({
|
||||
"tool": "mcp_tool_call",
|
||||
"server": CODEX_APPS_MCP_SERVER_NAME,
|
||||
"tool_name": "browser_navigate",
|
||||
"arguments": {
|
||||
"url": "https://example.com",
|
||||
},
|
||||
"connector_name": "Playwright",
|
||||
"tool_title": "Navigate",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn guardian_review_decision_maps_to_mcp_tool_decision() {
|
||||
let (session, _) = make_session_and_context().await;
|
||||
@@ -1773,24 +1783,6 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_callsite_mode_distinguishes_default_and_always_allow() {
|
||||
let (_session, turn_context) = make_session_and_context().await;
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
|
||||
"mcp_tool_call__default"
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Prompt, &turn_context),
|
||||
"mcp_tool_call__default"
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context),
|
||||
"mcp_tool_call__always_allow"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declined_elicitation_response_stays_decline() {
|
||||
let response = parse_mcp_tool_approval_elicitation_response(
|
||||
@@ -2290,6 +2282,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2302,7 +2295,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
&turn_context,
|
||||
"call-1",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
@@ -2363,6 +2356,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2375,7 +2369,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
|
||||
&turn_context,
|
||||
"call-guardian",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
)
|
||||
@@ -2419,6 +2413,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Create entities".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2431,7 +2426,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
|
||||
&turn_context,
|
||||
"call-mcp-hook",
|
||||
&invocation,
|
||||
"mcp__memory__create_entities",
|
||||
&HookToolName::new("mcp__memory__create_entities"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
)
|
||||
@@ -2493,7 +2488,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
|
||||
&turn_context,
|
||||
"call-mcp-hook-no-metadata",
|
||||
&invocation,
|
||||
"mcp__memory__create_entities",
|
||||
&HookToolName::new("mcp__memory__create_entities"),
|
||||
/*metadata*/ None,
|
||||
AppToolApproval::Auto,
|
||||
)
|
||||
@@ -2554,6 +2549,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Create entities".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2572,7 +2568,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
|
||||
&turn_context,
|
||||
"call-mcp-remembered",
|
||||
&invocation,
|
||||
"mcp__memory__create_entities",
|
||||
&HookToolName::new("mcp__memory__create_entities"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
)
|
||||
@@ -2640,6 +2636,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Reads calendar data.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2652,7 +2649,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
|
||||
&turn_context,
|
||||
"call-guardian-deny",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
)
|
||||
@@ -2693,6 +2690,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2709,7 +2707,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
&turn_context,
|
||||
"call-prompt",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Prompt,
|
||||
)
|
||||
@@ -2727,244 +2725,13 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_skips_arc_interrupt_for_model() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool: "dangerous_tool".to_string(),
|
||||
arguments: Some(serde_json::json!({ "id": 1 })),
|
||||
};
|
||||
let metadata = McpToolApprovalMetadata {
|
||||
annotations: Some(annotations(Some(false), Some(true), Some(true))),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-2",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_approve_mode_skips_arc_interrupt_for_model() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let invocation = McpInvocation {
|
||||
server: "docs".to_string(),
|
||||
tool: "dangerous_tool".to_string(),
|
||||
arguments: Some(serde_json::json!({ "id": 1 })),
|
||||
};
|
||||
let metadata = McpToolApprovalMetadata {
|
||||
annotations: Some(annotations(Some(false), Some(true), Some(true))),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-2-custom",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_skips_arc_interrupt_without_annotations() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool: "dangerous_tool".to_string(),
|
||||
arguments: Some(serde_json::json!({ "id": 1 })),
|
||||
};
|
||||
let metadata = McpToolApprovalMetadata {
|
||||
annotations: None,
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-3",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
turn_context
|
||||
.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("test setup should allow updating approval policy");
|
||||
turn_context.permission_profile = PermissionProfile::Disabled;
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
@@ -2978,6 +2745,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2995,7 +2763,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
&turn_context,
|
||||
"call-2",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
approval_mode,
|
||||
)
|
||||
@@ -3006,7 +2774,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
|
||||
async fn approve_mode_skips_guardian_in_every_permission_mode() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
@@ -3019,22 +2787,6 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "ask-user",
|
||||
"short_reason": "needs confirmation",
|
||||
"rationale": "ARC wants a second review",
|
||||
"risk_score": 65,
|
||||
"risk_level": "medium",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "requires review",
|
||||
}],
|
||||
})))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
@@ -3046,6 +2798,7 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -3098,7 +2851,7 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
|
||||
&turn_context,
|
||||
"call-3",
|
||||
&invocation,
|
||||
"mcp__test__tool",
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
|
||||
@@ -3,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,9 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_features::Feature;
|
||||
use codex_features::Features;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::ToolsConfigParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
@@ -78,7 +70,7 @@ fn numbered_mcp_tools(count: usize) -> Vec<ToolInfo> {
|
||||
make_mcp_tool(
|
||||
"rmcp",
|
||||
&tool_name,
|
||||
"mcp__rmcp__",
|
||||
"mcp__rmcp",
|
||||
&tool_name,
|
||||
/*connector_id*/ None,
|
||||
/*connector_name*/ None,
|
||||
@@ -94,38 +86,13 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
|
||||
let config = test_config().await;
|
||||
let model_info =
|
||||
construct_model_info_offline_for_tests("gpt-5.4", &config.to_models_manager_config());
|
||||
let features = Features::with_defaults();
|
||||
let available_models = Vec::new();
|
||||
let mut tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
tools_config.search_tool = search_tool;
|
||||
tools_config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directly_exposes_small_effective_tool_sets() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
/*connectors*/ None,
|
||||
&[],
|
||||
&config,
|
||||
&tools_config,
|
||||
&mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools));
|
||||
@@ -135,15 +102,10 @@ async fn directly_exposes_small_effective_tool_sets() {
|
||||
#[tokio::test]
|
||||
async fn searches_large_effective_tool_sets() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
/*connectors*/ None,
|
||||
&[],
|
||||
&config,
|
||||
&tools_config,
|
||||
&mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true,
|
||||
);
|
||||
|
||||
assert!(exposure.direct_tools.is_empty());
|
||||
@@ -155,69 +117,17 @@ async fn searches_large_effective_tool_sets() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directly_exposes_explicit_apps_without_deferred_overlap() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
|
||||
mcp_tools.push(make_mcp_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event",
|
||||
Some("calendar"),
|
||||
Some("Calendar"),
|
||||
));
|
||||
let connectors = vec![make_connector("calendar", "Calendar")];
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
Some(connectors.as_slice()),
|
||||
connectors.as_slice(),
|
||||
&config,
|
||||
&tools_config,
|
||||
);
|
||||
|
||||
let direct_tool_names = tool_names(&exposure.direct_tools);
|
||||
assert_eq!(
|
||||
direct_tool_names,
|
||||
HashSet::from([ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
exposure.deferred_tools.as_ref().map(Vec::len),
|
||||
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1)
|
||||
);
|
||||
let deferred_tools = exposure
|
||||
.deferred_tools
|
||||
.as_ref()
|
||||
.expect("large tool sets should be discoverable through tool_search");
|
||||
let deferred_tool_names = tool_names(deferred_tools);
|
||||
assert!(
|
||||
direct_tool_names.is_disjoint(&deferred_tool_names),
|
||||
"direct tools should not also be deferred: {direct_tool_names:?}"
|
||||
);
|
||||
assert!(!deferred_tool_names.contains(&ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)));
|
||||
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool_0")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_defer_feature_preserves_explicit_apps() {
|
||||
async fn always_defer_feature_defers_apps_too() {
|
||||
let mut config = test_config().await;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ToolSearchAlwaysDeferMcpTools)
|
||||
.expect("test config should allow feature update");
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = vec![
|
||||
make_mcp_tool(
|
||||
"rmcp",
|
||||
"tool",
|
||||
"mcp__rmcp__",
|
||||
"mcp__rmcp",
|
||||
"tool",
|
||||
/*connector_id*/ None,
|
||||
/*connector_name*/ None,
|
||||
@@ -236,26 +146,18 @@ async fn always_defer_feature_preserves_explicit_apps() {
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
Some(connectors.as_slice()),
|
||||
connectors.as_slice(),
|
||||
&config,
|
||||
&tools_config,
|
||||
/*search_tool_enabled*/ true,
|
||||
);
|
||||
|
||||
let direct_tool_names = tool_names(&exposure.direct_tools);
|
||||
assert_eq!(
|
||||
direct_tool_names,
|
||||
HashSet::from([ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)])
|
||||
);
|
||||
assert!(exposure.direct_tools.is_empty());
|
||||
let deferred_tools = exposure
|
||||
.deferred_tools
|
||||
.as_ref()
|
||||
.expect("MCP tools should be discoverable through tool_search");
|
||||
let deferred_tool_names = tool_names(deferred_tools);
|
||||
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool")));
|
||||
assert!(!deferred_tool_names.contains(&ToolName::namespaced(
|
||||
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp", "tool")));
|
||||
assert!(deferred_tool_names.contains(&ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)));
|
||||
|
||||
@@ -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(
|
||||
¶ms,
|
||||
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)]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::config::find_codex_home;
|
||||
use crate::config::is_builtin_permission_profile_name;
|
||||
use crate::config::reject_unknown_builtin_permission_profile;
|
||||
use crate::config::resolve_permission_profile;
|
||||
use crate::exec_policy::ExecPolicyError;
|
||||
use crate::exec_policy::format_exec_policy_error_with_source;
|
||||
@@ -13,6 +15,7 @@ use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
use codex_config::merge_toml_values;
|
||||
use codex_config::permissions_toml::NetworkMitmActionToml;
|
||||
use codex_config::permissions_toml::NetworkMitmHookToml;
|
||||
use codex_config::permissions_toml::NetworkMitmToml;
|
||||
@@ -122,6 +125,7 @@ fn network_constraints_from_trusted_layers(
|
||||
layers: &ConfigLayerStack,
|
||||
) -> Result<NetworkProxyConstraints> {
|
||||
let mut constraints = NetworkProxyConstraints::default();
|
||||
let mut merged = toml::Value::Table(toml::map::Map::new());
|
||||
for layer in layers.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
@@ -130,10 +134,12 @@ fn network_constraints_from_trusted_layers(
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed = network_tables_from_toml(&layer.config)?;
|
||||
if let Some(network) = selected_network_from_tables(parsed)? {
|
||||
apply_network_constraints(network, &mut constraints);
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
|
||||
let parsed = network_tables_from_toml(&merged)?;
|
||||
if let Some(network) = selected_network_from_tables(parsed)? {
|
||||
apply_network_constraints(network, &mut constraints);
|
||||
}
|
||||
Ok(constraints)
|
||||
}
|
||||
@@ -194,13 +200,17 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
|
||||
let Some(default_permissions) = parsed.default_permissions else {
|
||||
return Ok(None);
|
||||
};
|
||||
if is_builtin_permission_profile_name(&default_permissions) {
|
||||
return Ok(None);
|
||||
}
|
||||
reject_unknown_builtin_permission_profile(&default_permissions)?;
|
||||
|
||||
let permissions = parsed
|
||||
.permissions
|
||||
.context("default_permissions requires a `[permissions]` table for network settings")?;
|
||||
let profile = resolve_permission_profile(&permissions, &default_permissions)
|
||||
.map_err(anyhow::Error::from)?;
|
||||
Ok(profile.network.clone())
|
||||
Ok(profile.profile.network)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -262,14 +272,16 @@ fn config_from_layers(
|
||||
layers: &ConfigLayerStack,
|
||||
exec_policy: &codex_execpolicy::Policy,
|
||||
) -> Result<NetworkProxyConfig> {
|
||||
let mut accumulator = NetworkConfigAccumulator::default();
|
||||
let mut merged = toml::Value::Table(toml::map::Map::new());
|
||||
for layer in layers.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
let parsed = network_tables_from_toml(&layer.config)?;
|
||||
accumulator.apply_network_tables(parsed)?;
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
let parsed = network_tables_from_toml(&merged)?;
|
||||
let mut accumulator = NetworkConfigAccumulator::default();
|
||||
accumulator.apply_network_tables(parsed)?;
|
||||
let mut config = accumulator.finish()?;
|
||||
apply_exec_policy_network_rules(&mut config, exec_policy);
|
||||
Ok(config)
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
use super::*;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionToml;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn higher_precedence_profile_network_overlays_domain_entries() {
|
||||
let lower_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
[permissions.dev.network.domains]
|
||||
"lower.example.com" = "allow"
|
||||
"blocked.example.com" = "deny"
|
||||
"#,
|
||||
@@ -21,11 +29,11 @@ default_permissions = "workspace"
|
||||
.expect("lower layer should parse");
|
||||
let higher_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
[permissions.dev.network.domains]
|
||||
"higher.example.com" = "allow"
|
||||
"#,
|
||||
)
|
||||
@@ -60,11 +68,11 @@ default_permissions = "workspace"
|
||||
fn higher_precedence_profile_network_overrides_matching_domain_entries() {
|
||||
let lower_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
[permissions.dev.network.domains]
|
||||
"shared.example.com" = "deny"
|
||||
"other.example.com" = "allow"
|
||||
"#,
|
||||
@@ -72,11 +80,11 @@ default_permissions = "workspace"
|
||||
.expect("lower layer should parse");
|
||||
let higher_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
[permissions.dev.network.domains]
|
||||
"shared.example.com" = "allow"
|
||||
"#,
|
||||
)
|
||||
@@ -221,9 +229,9 @@ fn execpolicy_network_rules_overlay_network_lists() {
|
||||
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
dangerously_allow_all_unix_sockets = true
|
||||
"#,
|
||||
)
|
||||
@@ -240,15 +248,345 @@ dangerously_allow_all_unix_sockets = true
|
||||
assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_network_from_tables_ignores_builtin_profile_without_permissions_table() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = ":workspace"
|
||||
"#,
|
||||
)
|
||||
.expect("built-in profile config should parse");
|
||||
|
||||
let network = selected_network_from_tables(
|
||||
network_tables_from_toml(&config).expect("built-in profile config should deserialize"),
|
||||
)
|
||||
.expect("built-in profile selection should not require permissions tables");
|
||||
|
||||
assert_eq!(network, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_network_from_tables_rejects_unknown_builtin_profile_without_permissions_table() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = ":unknown"
|
||||
"#,
|
||||
)
|
||||
.expect("unknown built-in config should parse");
|
||||
|
||||
let err = selected_network_from_tables(
|
||||
network_tables_from_toml(&config).expect("unknown built-in config should deserialize"),
|
||||
)
|
||||
.expect_err("unknown built-in profile should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"default_permissions refers to unknown built-in profile `:unknown`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_network_from_tables_resolves_builtin_workspace_parent() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.dev]
|
||||
extends = ":workspace"
|
||||
|
||||
[permissions.dev.network]
|
||||
enabled = true
|
||||
|
||||
[permissions.dev.network.domains]
|
||||
"child.example.com" = "allow"
|
||||
"#,
|
||||
)
|
||||
.expect("dev extension config should parse");
|
||||
|
||||
let network = selected_network_from_tables(
|
||||
network_tables_from_toml(&config).expect("dev extension config should deserialize"),
|
||||
)
|
||||
.expect("dev extension should resolve")
|
||||
.expect("dev extension should expose child network config");
|
||||
|
||||
assert_eq!(
|
||||
network,
|
||||
NetworkToml {
|
||||
enabled: Some(true),
|
||||
domains: Some(NetworkDomainPermissionsToml {
|
||||
entries: BTreeMap::from([(
|
||||
"child.example.com".to_string(),
|
||||
NetworkDomainPermissionToml::Allow,
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_network_from_tables_resolves_permission_profile_inheritance() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.base.network]
|
||||
enabled = true
|
||||
dangerously_allow_all_unix_sockets = true
|
||||
|
||||
[permissions.base.network.domains]
|
||||
"base.example.com" = "allow"
|
||||
"shared.example.com" = "deny"
|
||||
|
||||
[permissions.dev]
|
||||
extends = "base"
|
||||
|
||||
[permissions.dev.network]
|
||||
allow_local_binding = true
|
||||
|
||||
[permissions.dev.network.domains]
|
||||
"child.example.com" = "allow"
|
||||
"shared.example.com" = "allow"
|
||||
"#,
|
||||
)
|
||||
.expect("permissions profiles should parse");
|
||||
|
||||
let network = selected_network_from_tables(
|
||||
network_tables_from_toml(&config).expect("permissions profiles should deserialize"),
|
||||
)
|
||||
.expect("permissions profiles should select a network table")
|
||||
.expect("network table should be present");
|
||||
|
||||
assert_eq!(
|
||||
network,
|
||||
NetworkToml {
|
||||
enabled: Some(true),
|
||||
dangerously_allow_all_unix_sockets: Some(true),
|
||||
allow_local_binding: Some(true),
|
||||
domains: Some(NetworkDomainPermissionsToml {
|
||||
entries: BTreeMap::from([
|
||||
(
|
||||
"base.example.com".to_string(),
|
||||
NetworkDomainPermissionToml::Allow,
|
||||
),
|
||||
(
|
||||
"child.example.com".to_string(),
|
||||
NetworkDomainPermissionToml::Allow,
|
||||
),
|
||||
(
|
||||
"shared.example.com".to_string(),
|
||||
NetworkDomainPermissionToml::Allow,
|
||||
),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_layers_resolves_inherited_profiles_across_layers() {
|
||||
let lower_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::toml! {
|
||||
[permissions.base.network.domains]
|
||||
"base.example.com" = "allow"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let higher_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::toml! {
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.dev]
|
||||
extends = "base"
|
||||
|
||||
[permissions.dev.network.domains]
|
||||
"child.example.com" = "allow"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let layers = ConfigLayerStack::new(
|
||||
vec![lower_layer, higher_layer],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("layer stack should be valid");
|
||||
|
||||
let config =
|
||||
config_from_layers(&layers, &Policy::empty()).expect("inherited profiles should load");
|
||||
|
||||
assert_eq!(
|
||||
config.network.allowed_domains(),
|
||||
Some(vec![
|
||||
"base.example.com".to_string(),
|
||||
"child.example.com".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_layers_normalizes_profile_network_domains_before_merging_layers() {
|
||||
let lower_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::toml! {
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.dev.network.domains]
|
||||
"example.com" = "deny"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let higher_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::toml! {
|
||||
[permissions.dev.network.domains]
|
||||
"EXAMPLE.COM" = "allow"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let layers = ConfigLayerStack::new(
|
||||
vec![lower_layer, higher_layer],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("layer stack should be valid");
|
||||
|
||||
let config = config_from_layers(&layers, &Policy::empty())
|
||||
.expect("network domain layer precedence should load");
|
||||
|
||||
assert_eq!(
|
||||
config.network.allowed_domains(),
|
||||
Some(vec!["example.com".to_string()])
|
||||
);
|
||||
assert_eq!(config.network.denied_domains(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_layers_uses_only_the_final_selected_profile_network() {
|
||||
let lower_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::toml! {
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.dev.network.domains]
|
||||
"lower.example.com" = "allow"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let higher_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::toml! {
|
||||
default_permissions = ":workspace"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let layers = ConfigLayerStack::new(
|
||||
vec![lower_layer, higher_layer],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("layer stack should be valid");
|
||||
|
||||
let config = config_from_layers(&layers, &Policy::empty())
|
||||
.expect("final built-in profile selection should load");
|
||||
|
||||
assert_eq!(config.network.allowed_domains(), None);
|
||||
assert_eq!(config.network.denied_domains(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_constraints_use_only_the_final_selected_profile_network() {
|
||||
let lower_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System {
|
||||
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/system.toml"))
|
||||
.expect("system config path should be absolute"),
|
||||
},
|
||||
toml::toml! {
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.dev.network.domains]
|
||||
"managed.example.com" = "allow"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let higher_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
||||
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/managed.toml"))
|
||||
.expect("managed config path should be absolute"),
|
||||
},
|
||||
toml::toml! {
|
||||
default_permissions = ":workspace"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let layers = ConfigLayerStack::new(
|
||||
vec![lower_layer, higher_layer],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("layer stack should be valid");
|
||||
|
||||
let constraints = network_constraints_from_trusted_layers(&layers)
|
||||
.expect("final built-in trusted selection should load");
|
||||
|
||||
assert_eq!(constraints.allowed_domains, None);
|
||||
assert_eq!(constraints.denied_domains, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_constraints_normalize_profile_network_domains_before_merging_layers() {
|
||||
let lower_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System {
|
||||
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/system.toml"))
|
||||
.expect("system config path should be absolute"),
|
||||
},
|
||||
toml::toml! {
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.dev.network.domains]
|
||||
"example.com" = "deny"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let higher_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
||||
file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/managed.toml"))
|
||||
.expect("managed config path should be absolute"),
|
||||
},
|
||||
toml::toml! {
|
||||
[permissions.dev.network.domains]
|
||||
"EXAMPLE.COM" = "allow"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let layers = ConfigLayerStack::new(
|
||||
vec![lower_layer, higher_layer],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("layer stack should be valid");
|
||||
|
||||
let constraints = network_constraints_from_trusted_layers(&layers)
|
||||
.expect("trusted network domain layer precedence should load");
|
||||
|
||||
assert_eq!(
|
||||
constraints.allowed_domains,
|
||||
Some(vec!["example.com".to_string()])
|
||||
);
|
||||
assert_eq!(constraints.denied_domains, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_network_constraints_skips_empty_domain_sides() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
[permissions.dev.network.domains]
|
||||
"managed.example.com" = "allow"
|
||||
"#,
|
||||
)
|
||||
@@ -273,22 +611,22 @@ default_permissions = "workspace"
|
||||
fn apply_network_constraints_overlay_domain_entries() {
|
||||
let lower_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
[permissions.dev.network.domains]
|
||||
"blocked.example.com" = "deny"
|
||||
"#,
|
||||
)
|
||||
.expect("lower layer should parse");
|
||||
let higher_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
default_permissions = "dev"
|
||||
|
||||
[permissions.workspace.network]
|
||||
[permissions.dev.network]
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
[permissions.dev.network.domains]
|
||||
"api.example.com" = "allow"
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -32,17 +32,14 @@ pub async fn maybe_migrate_personality(
|
||||
return Ok(PersonalityMigrationStatus::SkippedMarker);
|
||||
}
|
||||
|
||||
let config_profile = config_toml
|
||||
.get_config_profile(/*override_profile*/ None)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
if config_toml.personality.is_some() || config_profile.personality.is_some() {
|
||||
if config_toml.personality.is_some() {
|
||||
create_marker(&marker_path).await?;
|
||||
return Ok(PersonalityMigrationStatus::SkippedExplicitPersonality);
|
||||
}
|
||||
|
||||
let model_provider_id = config_profile
|
||||
let model_provider_id = config_toml
|
||||
.model_provider
|
||||
.or_else(|| config_toml.model_provider.clone())
|
||||
.clone()
|
||||
.unwrap_or_else(|| "openai".to_string());
|
||||
|
||||
if !has_recorded_sessions(codex_home, model_provider_id.as_str(), state_db).await? {
|
||||
|
||||
@@ -72,6 +72,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
@@ -46,9 +45,12 @@ pub async fn build_prompt_input(
|
||||
Arc::clone(&auth_manager),
|
||||
SessionSource::Exec,
|
||||
Arc::new(
|
||||
EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths)
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
|
||||
EnvironmentManager::from_codex_home(
|
||||
config.codex_home.clone(),
|
||||
Some(local_runtime_paths),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
|
||||
),
|
||||
empty_extension_registry(),
|
||||
/*analytics_events_client*/ None,
|
||||
@@ -86,15 +88,7 @@ pub(crate) async fn build_prompt_input_from_session(
|
||||
.clone_history()
|
||||
.await
|
||||
.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let router = built_tools(
|
||||
sess,
|
||||
turn_context.as_ref(),
|
||||
&prompt_input,
|
||||
&HashSet::new(),
|
||||
Some(turn_context.turn_skills.outcome.as_ref()),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let router = built_tools(sess, turn_context.as_ref(), &CancellationToken::new()).await?;
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let prompt = build_prompt(
|
||||
prompt_input,
|
||||
|
||||
@@ -641,12 +641,14 @@ async fn prepare_realtime_start(
|
||||
realtime_request_headers(
|
||||
requested_realtime_session_id.as_deref(),
|
||||
Some(realtime_api_key.as_str()),
|
||||
version,
|
||||
)?
|
||||
}
|
||||
ConversationStartTransport::Webrtc { .. } => {
|
||||
realtime_request_headers(
|
||||
requested_realtime_session_id.as_deref(),
|
||||
/*api_key*/ None,
|
||||
version,
|
||||
)?
|
||||
}
|
||||
};
|
||||
@@ -973,9 +975,14 @@ fn realtime_api_key(auth: Option<&CodexAuth>, provider: &ModelProviderInfo) -> C
|
||||
fn realtime_request_headers(
|
||||
realtime_session_id: Option<&str>,
|
||||
api_key: Option<&str>,
|
||||
version: RealtimeWsVersion,
|
||||
) -> CodexResult<Option<HeaderMap>> {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
if version == RealtimeWsVersion::V1 {
|
||||
headers.insert("openai-alpha", HeaderValue::from_static("quicksilver=v1"));
|
||||
}
|
||||
|
||||
if let Some(realtime_session_id) = realtime_session_id
|
||||
&& let Ok(realtime_session_id) = HeaderValue::from_str(realtime_session_id)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use super::RealtimeHandoffState;
|
||||
use super::RealtimeSessionKind;
|
||||
use super::realtime_delegation_from_handoff;
|
||||
use super::realtime_request_headers;
|
||||
use super::realtime_text_from_handoff_request;
|
||||
use super::wrap_realtime_delegation_input;
|
||||
use async_channel::bounded;
|
||||
use codex_config::config_toml::RealtimeWsVersion;
|
||||
use codex_protocol::protocol::RealtimeHandoffRequested;
|
||||
use codex_protocol::protocol::RealtimeTranscriptEntry;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -137,3 +139,28 @@ async fn clears_active_handoff_explicitly() {
|
||||
*state.active_handoff.lock().await = None;
|
||||
assert_eq!(state.active_handoff.lock().await.clone(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_quicksilver_alpha_header_for_realtime_v1() {
|
||||
let headers =
|
||||
realtime_request_headers(Some("session_1"), Some("sk-test"), RealtimeWsVersion::V1)
|
||||
.expect("headers")
|
||||
.expect("headers");
|
||||
|
||||
assert_eq!(
|
||||
headers
|
||||
.get("openai-alpha")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("quicksilver=v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_quicksilver_alpha_header_for_realtime_v2() {
|
||||
let headers =
|
||||
realtime_request_headers(Some("session_1"), Some("sk-test"), RealtimeWsVersion::V2)
|
||||
.expect("headers")
|
||||
.expect("headers");
|
||||
|
||||
assert!(headers.get("openai-alpha").is_none());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Shared retry and transport fallback decisions for Responses requests.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::util::backoff;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum ResponsesStreamRequest {
|
||||
Sampling,
|
||||
RemoteCompactionV2,
|
||||
}
|
||||
|
||||
/// Handles a retryable stream error and returns `Ok(())` when the caller should
|
||||
/// retry the request loop.
|
||||
pub(crate) async fn handle_retryable_response_stream_error(
|
||||
retries: &mut u64,
|
||||
max_retries: u64,
|
||||
err: CodexErr,
|
||||
client_session: &mut ModelClientSession,
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
request: ResponsesStreamRequest,
|
||||
) -> Result<(), CodexErr> {
|
||||
if *retries >= max_retries
|
||||
&& client_session.try_switch_fallback_transport(
|
||||
&turn_context.session_telemetry,
|
||||
&turn_context.model_info,
|
||||
)
|
||||
{
|
||||
sess.send_event(
|
||||
turn_context,
|
||||
EventMsg::Warning(WarningEvent {
|
||||
message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
*retries = 0;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if *retries < max_retries {
|
||||
*retries += 1;
|
||||
let retry_count = *retries;
|
||||
let delay = match &err {
|
||||
CodexErr::Stream(_, requested_delay) => {
|
||||
requested_delay.unwrap_or_else(|| backoff(retry_count))
|
||||
}
|
||||
_ => backoff(retry_count),
|
||||
};
|
||||
log_retry(request, turn_context, &err, retry_count, max_retries, delay);
|
||||
|
||||
// In release builds, hide the first websocket retry notification to reduce noisy
|
||||
// transient reconnect messages. In debug builds, keep full visibility for diagnosis.
|
||||
let report_error = retry_count > 1
|
||||
|| cfg!(debug_assertions)
|
||||
|| !sess.services.model_client.responses_websocket_enabled();
|
||||
if report_error {
|
||||
// Surface retry information to any UI/front-end so the user understands what is
|
||||
// happening instead of staring at a seemingly frozen screen.
|
||||
sess.notify_stream_error(
|
||||
turn_context,
|
||||
format!("Reconnecting... {retry_count}/{max_retries}"),
|
||||
err,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(err)
|
||||
}
|
||||
|
||||
fn log_retry(
|
||||
request: ResponsesStreamRequest,
|
||||
turn_context: &TurnContext,
|
||||
err: &CodexErr,
|
||||
retries: u64,
|
||||
max_retries: u64,
|
||||
delay: Duration,
|
||||
) {
|
||||
match request {
|
||||
ResponsesStreamRequest::Sampling => {
|
||||
warn!(
|
||||
"stream disconnected - retrying sampling request ({retries}/{max_retries} in {delay:?})...",
|
||||
);
|
||||
}
|
||||
ResponsesStreamRequest::RemoteCompactionV2 => {
|
||||
warn!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
retries,
|
||||
max_retries,
|
||||
compact_error = %err,
|
||||
"remote compaction v2 stream failed; retrying request after delay"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,15 +42,15 @@ use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::protocol::ThreadSettingsSnapshot;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
||||
@@ -83,19 +83,6 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn override_turn_context(sess: &Session, sub_id: String, updates: SessionSettingsUpdate) {
|
||||
if let Err(err) = sess.update_settings(updates).await {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: err.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
|
||||
user_input_or_turn_inner(
|
||||
sess,
|
||||
@@ -106,140 +93,132 @@ pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn update_thread_settings(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
thread_settings: ThreadSettingsOverrides,
|
||||
) {
|
||||
let updates = thread_settings_update(sess, thread_settings).await;
|
||||
let msg = match sess.update_settings(updates).await {
|
||||
Ok(()) => thread_settings_applied_event(sess).await,
|
||||
Err(err) => EventMsg::Error(ErrorEvent {
|
||||
message: format!("invalid thread settings override: {err}"),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(Event { id: sub_id, msg }).await;
|
||||
}
|
||||
|
||||
async fn thread_settings_update(
|
||||
sess: &Session,
|
||||
thread_settings: ThreadSettingsOverrides,
|
||||
) -> SessionSettingsUpdate {
|
||||
let ThreadSettingsOverrides {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} = thread_settings;
|
||||
let collaboration_mode = match collaboration_mode {
|
||||
Some(collaboration_mode) => collaboration_mode,
|
||||
None => {
|
||||
let state = sess.state.lock().await;
|
||||
// Model and reasoning effort live in CollaborationMode settings today, so
|
||||
// partial thread-settings updates refresh those fields on the active mode.
|
||||
state
|
||||
.session_configuration
|
||||
.collaboration_mode
|
||||
.with_updates(model, effort, /*developer_instructions*/ None)
|
||||
}
|
||||
};
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
personality,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn thread_settings_applied_event(sess: &Session) -> EventMsg {
|
||||
let snapshot = {
|
||||
let state = sess.state.lock().await;
|
||||
state.session_configuration.thread_config_snapshot()
|
||||
};
|
||||
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
|
||||
thread_settings: ThreadSettingsSnapshot {
|
||||
model: snapshot.model,
|
||||
model_provider_id: snapshot.model_provider_id,
|
||||
service_tier: snapshot.service_tier,
|
||||
approval_policy: snapshot.approval_policy,
|
||||
approvals_reviewer: snapshot.approvals_reviewer,
|
||||
permission_profile: snapshot.permission_profile,
|
||||
active_permission_profile: snapshot.active_permission_profile,
|
||||
cwd: snapshot.cwd,
|
||||
reasoning_effort: snapshot.reasoning_effort,
|
||||
reasoning_summary: snapshot.reasoning_summary,
|
||||
personality: snapshot.personality,
|
||||
collaboration_mode: snapshot.collaboration_mode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn user_input_or_turn_inner(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
op: Op,
|
||||
mirror_user_text_to_realtime: Option<()>,
|
||||
) {
|
||||
let (items, updates, responsesapi_client_metadata) = match op {
|
||||
Op::UserTurn {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
items,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
environments,
|
||||
} => {
|
||||
let collaboration_mode = collaboration_mode.or_else(|| {
|
||||
Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: model.clone(),
|
||||
reasoning_effort: effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
})
|
||||
});
|
||||
(
|
||||
items,
|
||||
SessionSettingsUpdate {
|
||||
cwd: Some(cwd),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer,
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
workspace_roots: None,
|
||||
profile_workspace_roots: None,
|
||||
permission_profile,
|
||||
active_permission_profile: None,
|
||||
windows_sandbox_level: None,
|
||||
collaboration_mode,
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
environments,
|
||||
personality,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
Op::UserInputWithTurnContext {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
items,
|
||||
responsesapi_client_metadata,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
environments,
|
||||
} => {
|
||||
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
|
||||
Some(collab_mode)
|
||||
} else {
|
||||
let state = sess.state.lock().await;
|
||||
Some(
|
||||
state
|
||||
.session_configuration
|
||||
.collaboration_mode
|
||||
.with_updates(model, effort, /*developer_instructions*/ None),
|
||||
)
|
||||
};
|
||||
(
|
||||
items,
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
collaboration_mode,
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
environments,
|
||||
personality,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
},
|
||||
responsesapi_client_metadata,
|
||||
)
|
||||
}
|
||||
Op::UserInput {
|
||||
items,
|
||||
environments,
|
||||
final_output_json_schema,
|
||||
responsesapi_client_metadata,
|
||||
} => (
|
||||
items,
|
||||
SessionSettingsUpdate {
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
environments,
|
||||
..Default::default()
|
||||
},
|
||||
responsesapi_client_metadata,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
let Op::UserInput {
|
||||
items,
|
||||
environments,
|
||||
final_output_json_schema,
|
||||
responsesapi_client_metadata,
|
||||
thread_settings,
|
||||
} = op
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
let emit_thread_settings_applied = thread_settings != ThreadSettingsOverrides::default();
|
||||
let mut updates = if emit_thread_settings_applied {
|
||||
thread_settings_update(sess, thread_settings).await
|
||||
} else {
|
||||
SessionSettingsUpdate::default()
|
||||
};
|
||||
updates.final_output_json_schema = Some(final_output_json_schema);
|
||||
updates.environments = environments;
|
||||
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
|
||||
// new_turn_with_sub_id already emits the error event.
|
||||
return;
|
||||
};
|
||||
if emit_thread_settings_applied {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id.clone(),
|
||||
msg: thread_settings_applied_event(sess).await,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
||||
.await;
|
||||
let accepted_items = match sess
|
||||
@@ -319,7 +298,9 @@ pub async fn inter_agent_communication(
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
let trigger_turn = communication.trigger_turn;
|
||||
sess.enqueue_mailbox_communication(communication);
|
||||
sess.input_queue
|
||||
.enqueue_mailbox_communication(communication)
|
||||
.await;
|
||||
if trigger_turn {
|
||||
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
|
||||
.await;
|
||||
@@ -479,16 +460,8 @@ pub async fn reload_user_config(sess: &Arc<Session>) {
|
||||
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
|
||||
sess.spawn_task(
|
||||
Arc::clone(&turn_context),
|
||||
vec![UserInput::Text {
|
||||
text: turn_context.compact_prompt().to_string(),
|
||||
// Compaction prompt is synthesized; no UI element ranges to preserve.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
CompactTask,
|
||||
)
|
||||
.await;
|
||||
sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn thread_rollback(sess: &Arc<Session>, sub_id: String, num_turns: u32) {
|
||||
@@ -638,12 +611,14 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
|
||||
sess.guardian_review_session.shutdown().await;
|
||||
}
|
||||
|
||||
fn emit_thread_stop_lifecycle(sess: &Session) {
|
||||
async fn emit_thread_stop_lifecycle(sess: &Session) {
|
||||
for contributor in sess.services.extensions.thread_lifecycle_contributors() {
|
||||
contributor.on_thread_stop(codex_extension_api::ThreadStopInput {
|
||||
session_store: &sess.services.session_extension_data,
|
||||
thread_store: &sess.services.thread_extension_data,
|
||||
});
|
||||
contributor
|
||||
.on_thread_stop(codex_extension_api::ThreadStopInput {
|
||||
session_store: &sess.services.session_extension_data,
|
||||
thread_store: &sess.services.thread_extension_data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +637,7 @@ pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
|
||||
&[],
|
||||
);
|
||||
|
||||
emit_thread_stop_lifecycle(sess.as_ref());
|
||||
emit_thread_stop_lifecycle(sess.as_ref()).await;
|
||||
|
||||
// Gracefully flush and shutdown thread persistence on session end so tests
|
||||
// that inspect durable state do not race with the background writer.
|
||||
@@ -781,54 +756,12 @@ pub(super) async fn submission_loop(
|
||||
realtime_conversation_list_voices(&sess, sub.id.clone()).await;
|
||||
false
|
||||
}
|
||||
Op::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} => {
|
||||
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
|
||||
collab_mode
|
||||
} else {
|
||||
let state = sess.state.lock().await;
|
||||
state.session_configuration.collaboration_mode.with_updates(
|
||||
model.clone(),
|
||||
effort,
|
||||
/*developer_instructions*/ None,
|
||||
)
|
||||
};
|
||||
override_turn_context(
|
||||
&sess,
|
||||
sub.id.clone(),
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
personality,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Op::UserInput { .. } => {
|
||||
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
|
||||
false
|
||||
}
|
||||
Op::UserInput { .. }
|
||||
| Op::UserInputWithTurnContext { .. }
|
||||
| Op::UserTurn { .. } => {
|
||||
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
|
||||
Op::ThreadSettings { thread_settings } => {
|
||||
update_thread_settings(&sess, sub.id.clone(), thread_settings).await;
|
||||
false
|
||||
}
|
||||
Op::InterAgentCommunication { communication } => {
|
||||
@@ -917,7 +850,7 @@ pub(super) async fn submission_loop(
|
||||
// explicit shutdown op, still run session teardown.
|
||||
if !shutdown_received {
|
||||
shutdown_session_runtime(&sess).await;
|
||||
emit_thread_stop_lifecycle(sess.as_ref());
|
||||
emit_thread_stop_lifecycle(sess.as_ref()).await;
|
||||
}
|
||||
debug!("Agent loop exited");
|
||||
}
|
||||
@@ -959,7 +892,9 @@ Approved action:
|
||||
}];
|
||||
|
||||
if let Err(items) = sess.inject_response_items(items).await {
|
||||
sess.queue_response_items_for_next_turn(items).await;
|
||||
sess.input_queue
|
||||
.queue_response_items_for_next_turn(items)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::MailboxDeliveryPhase;
|
||||
use crate::state::TurnState;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum TurnInput {
|
||||
UserInput(Vec<UserInput>),
|
||||
ResponseInputItem(ResponseInputItem),
|
||||
}
|
||||
|
||||
/// Turn-local pending input storage owned by the input queue flow.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TurnInputQueue {
|
||||
items: Vec<TurnInput>,
|
||||
}
|
||||
|
||||
/// Session-scoped pending input storage and active-turn mailbox delivery coordination.
|
||||
pub(crate) struct InputQueue {
|
||||
mailbox_tx: watch::Sender<()>,
|
||||
mailbox_pending_mails: Mutex<VecDeque<InterAgentCommunication>>,
|
||||
|
||||
idle_pending_input: Mutex<Vec<ResponseInputItem>>,
|
||||
}
|
||||
|
||||
impl InputQueue {
|
||||
pub(crate) fn new() -> Self {
|
||||
let (mailbox_tx, _) = watch::channel(());
|
||||
Self {
|
||||
mailbox_tx,
|
||||
mailbox_pending_mails: Mutex::new(VecDeque::new()),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribe_mailbox(&self) -> watch::Receiver<()> {
|
||||
let mut mailbox_rx = self.mailbox_tx.subscribe();
|
||||
if self.has_pending_mailbox_items().await {
|
||||
mailbox_rx.mark_changed();
|
||||
}
|
||||
mailbox_rx
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_mailbox_communication(
|
||||
&self,
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
self.mailbox_pending_mails
|
||||
.lock()
|
||||
.await
|
||||
.push_back(communication);
|
||||
self.mailbox_tx.send_replace(());
|
||||
}
|
||||
|
||||
pub(crate) async fn has_pending_mailbox_items(&self) -> bool {
|
||||
!self.mailbox_pending_mails.lock().await.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool {
|
||||
self.mailbox_pending_mails
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.any(|mail| mail.trigger_turn)
|
||||
}
|
||||
|
||||
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<ResponseInputItem> {
|
||||
self.mailbox_pending_mails
|
||||
.lock()
|
||||
.await
|
||||
.drain(..)
|
||||
.map(|mail| mail.to_response_input_item())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.idle_pending_input.lock().await.extend(items);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) async fn turn_state_for_sub_id(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
sub_id: &str,
|
||||
) -> Option<Arc<Mutex<TurnState>>> {
|
||||
let active = active_turn.lock().await;
|
||||
active.as_ref().and_then(|active_turn| {
|
||||
active_turn
|
||||
.task
|
||||
.as_ref()
|
||||
.is_some_and(|task| task.turn_context.sub_id == sub_id)
|
||||
.then(|| Arc::clone(&active_turn.turn_state))
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear any pending waiters and input buffered for the current turn.
|
||||
pub(crate) async fn clear_pending(&self, active_turn: &ActiveTurn) {
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.clear_pending_waiters();
|
||||
turn_state.pending_input.items.clear();
|
||||
}
|
||||
|
||||
pub(crate) async fn defer_mailbox_delivery_to_next_turn(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
sub_id: &str,
|
||||
) {
|
||||
let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
if !turn_state.pending_input.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn);
|
||||
}
|
||||
|
||||
pub(crate) async fn accept_mailbox_delivery_for_current_turn(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
sub_id: &str,
|
||||
) {
|
||||
let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
self.accept_mailbox_delivery_for_turn_state(turn_state.as_ref())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn accept_mailbox_delivery_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
) {
|
||||
turn_state
|
||||
.lock()
|
||||
.await
|
||||
.accept_mailbox_delivery_for_current_turn();
|
||||
}
|
||||
|
||||
pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
input: TurnInput,
|
||||
) {
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
turn_state.pending_input.items.push(input);
|
||||
turn_state.accept_mailbox_delivery_for_current_turn();
|
||||
}
|
||||
|
||||
pub(crate) async fn extend_pending_input_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
input: Vec<TurnInput>,
|
||||
) {
|
||||
turn_state.lock().await.pending_input.items.extend(input);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_pending_input_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
) -> Vec<TurnInput> {
|
||||
turn_state.lock().await.pending_input.items.split_off(0)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub(crate) async fn inject_response_items(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
input: Vec<ResponseInputItem>,
|
||||
) -> Result<(), Vec<ResponseInputItem>> {
|
||||
let mut active = active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(active_turn) => {
|
||||
self.extend_pending_input_for_turn_state(
|
||||
active_turn.turn_state.as_ref(),
|
||||
input
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseInputItem)
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(input),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub(crate) async fn get_pending_input(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
) -> Vec<TurnInput> {
|
||||
let (pending_input, accepts_mailbox_delivery) = {
|
||||
let mut active = active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(active_turn) => {
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
(
|
||||
turn_state.pending_input.items.split_off(0),
|
||||
turn_state.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), true),
|
||||
}
|
||||
};
|
||||
if !accepts_mailbox_delivery {
|
||||
return pending_input;
|
||||
}
|
||||
let mailbox_items = self
|
||||
.drain_mailbox_input_items()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseInputItem);
|
||||
if pending_input.is_empty() {
|
||||
mailbox_items.collect()
|
||||
} else {
|
||||
let mut pending_input = pending_input;
|
||||
pending_input.extend(mailbox_items);
|
||||
pending_input
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state reads must remain atomic"
|
||||
)]
|
||||
pub(crate) async fn has_pending_input(&self, active_turn: &Mutex<Option<ActiveTurn>>) -> bool {
|
||||
let (has_turn_pending_input, accepts_mailbox_delivery) = {
|
||||
let active = active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(active_turn) => {
|
||||
let turn_state = active_turn.turn_state.lock().await;
|
||||
(
|
||||
!turn_state.pending_input.items.is_empty(),
|
||||
turn_state.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (false, true),
|
||||
}
|
||||
};
|
||||
if has_turn_pending_input {
|
||||
return true;
|
||||
}
|
||||
if !accepts_mailbox_delivery {
|
||||
return false;
|
||||
}
|
||||
self.has_pending_mailbox_items().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::AgentPath;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn make_mail(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
content: &str,
|
||||
trigger_turn: bool,
|
||||
) -> InterAgentCommunication {
|
||||
InterAgentCommunication::new(
|
||||
author,
|
||||
recipient,
|
||||
Vec::new(),
|
||||
content.to_string(),
|
||||
trigger_turn,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn input_queue_notifies_mailbox_subscribers() {
|
||||
let input_queue = InputQueue::new();
|
||||
let mut mailbox_rx = input_queue.subscribe_mailbox().await;
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"one",
|
||||
/*trigger_turn*/ false,
|
||||
))
|
||||
.await;
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"two",
|
||||
/*trigger_turn*/ false,
|
||||
))
|
||||
.await;
|
||||
|
||||
mailbox_rx.changed().await.expect("mailbox update");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn input_queue_drains_mailbox_in_delivery_order() {
|
||||
let input_queue = InputQueue::new();
|
||||
let mail_one = make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"one",
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let mail_two = make_mail(
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
AgentPath::root(),
|
||||
"two",
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(mail_one.clone())
|
||||
.await;
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(mail_two.clone())
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
input_queue.drain_mailbox_input_items().await,
|
||||
vec![
|
||||
mail_one.to_response_input_item(),
|
||||
mail_two.to_response_input_item()
|
||||
]
|
||||
);
|
||||
assert!(!input_queue.has_pending_mailbox_items().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn input_queue_tracks_pending_trigger_turn_mail() {
|
||||
let input_queue = InputQueue::new();
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"queued",
|
||||
/*trigger_turn*/ false,
|
||||
))
|
||||
.await;
|
||||
assert!(!input_queue.has_trigger_turn_mailbox_items().await);
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"wake",
|
||||
/*trigger_turn*/ true,
|
||||
))
|
||||
.await;
|
||||
assert!(input_queue.has_trigger_turn_mailbox_items().await);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use codex_mcp::ElicitationReviewerHandle;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY;
|
||||
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL;
|
||||
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION;
|
||||
use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY as MCP_ELICITATION_APPROVALS_REVIEWER_KEY;
|
||||
use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY;
|
||||
use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_ELICITATION_CONNECTOR_ID_KEY;
|
||||
@@ -21,6 +22,10 @@ use rmcp::model::Meta;
|
||||
use serde_json::Map;
|
||||
|
||||
const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message";
|
||||
const TOOL_SUGGESTION_ACTION_INSTALL: &str = "install";
|
||||
const TOOL_SUGGESTION_ACTION_KEY: &str = "suggest_type";
|
||||
const TOOL_SUGGESTION_TOOL_ID_KEY: &str = "tool_id";
|
||||
const TOOL_SUGGESTION_TOOL_TYPE_KEY: &str = "tool_type";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum GuardianElicitationReview {
|
||||
@@ -33,6 +38,18 @@ struct GuardianMcpElicitationReviewer {
|
||||
session: std::sync::Weak<Session>,
|
||||
}
|
||||
|
||||
pub(crate) struct McpServerElicitationOutcome {
|
||||
pub(crate) response: Option<ElicitationResponse>,
|
||||
pub(crate) sent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct PluginInstallElicitationTelemetryMetadata {
|
||||
tool_type: String,
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
}
|
||||
|
||||
impl GuardianMcpElicitationReviewer {
|
||||
fn new(session: &Arc<Session>) -> Self {
|
||||
Self {
|
||||
@@ -70,7 +87,7 @@ impl Session {
|
||||
turn_context: &TurnContext,
|
||||
request_id: RequestId,
|
||||
params: McpServerElicitationRequestParams,
|
||||
) -> Option<ElicitationResponse> {
|
||||
) -> McpServerElicitationOutcome {
|
||||
if self
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
@@ -78,11 +95,14 @@ impl Session {
|
||||
.await
|
||||
.elicitations_auto_deny()
|
||||
{
|
||||
return Some(ElicitationResponse {
|
||||
action: codex_rmcp_client::ElicitationAction::Accept,
|
||||
content: Some(serde_json::json!({})),
|
||||
meta: None,
|
||||
});
|
||||
return McpServerElicitationOutcome {
|
||||
response: Some(ElicitationResponse {
|
||||
action: codex_rmcp_client::ElicitationAction::Accept,
|
||||
content: Some(serde_json::json!({})),
|
||||
meta: None,
|
||||
}),
|
||||
sent: false,
|
||||
};
|
||||
}
|
||||
|
||||
let server_name = params.server_name.clone();
|
||||
@@ -98,7 +118,10 @@ impl Session {
|
||||
warn!(
|
||||
"failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}"
|
||||
);
|
||||
return None;
|
||||
return McpServerElicitationOutcome {
|
||||
response: None,
|
||||
sent: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
codex_protocol::approvals::ElicitationRequest::Form {
|
||||
@@ -154,11 +177,24 @@ impl Session {
|
||||
id,
|
||||
request,
|
||||
});
|
||||
let plugin_install_telemetry = plugin_install_elicitation_telemetry_metadata(&event);
|
||||
turn_context
|
||||
.turn_metadata_state
|
||||
.mark_user_input_requested_during_turn();
|
||||
self.send_event(turn_context, event).await;
|
||||
rx_response.await.ok()
|
||||
if let Some(plugin_install_telemetry) = plugin_install_telemetry {
|
||||
turn_context
|
||||
.session_telemetry
|
||||
.record_plugin_install_elicitation_sent(
|
||||
plugin_install_telemetry.tool_type.as_str(),
|
||||
plugin_install_telemetry.tool_id.as_str(),
|
||||
plugin_install_telemetry.tool_name.as_str(),
|
||||
);
|
||||
}
|
||||
McpServerElicitationOutcome {
|
||||
response: rx_response.await.ok(),
|
||||
sent: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
@@ -289,16 +325,13 @@ impl Session {
|
||||
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
|
||||
let auth_statuses =
|
||||
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
|
||||
let mcp_runtime_environment = match turn_context.environments.primary() {
|
||||
Some(turn_environment) => McpRuntimeEnvironment::new(
|
||||
Arc::clone(&turn_environment.environment),
|
||||
let mcp_runtime_context = match turn_context.environments.primary() {
|
||||
Some(turn_environment) => McpRuntimeContext::new(
|
||||
Arc::clone(&self.services.environment_manager),
|
||||
turn_environment.cwd.to_path_buf(),
|
||||
),
|
||||
None => McpRuntimeEnvironment::new(
|
||||
self.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
|
||||
None => McpRuntimeContext::new(
|
||||
Arc::clone(&self.services.environment_manager),
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.to_path_buf(),
|
||||
),
|
||||
@@ -316,10 +349,11 @@ impl Session {
|
||||
turn_context.sub_id.clone(),
|
||||
self.get_tx_event(),
|
||||
turn_context.permission_profile(),
|
||||
mcp_runtime_environment,
|
||||
mcp_runtime_context,
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
host_owned_codex_apps_enabled,
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
mcp_config.client_elicitation_capability,
|
||||
tool_plugin_provenance,
|
||||
auth.as_ref(),
|
||||
@@ -550,6 +584,33 @@ fn metadata_owned_string(meta: &Map<String, Value>, key: &str) -> Option<String>
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn plugin_install_elicitation_telemetry_metadata(
|
||||
event: &EventMsg,
|
||||
) -> Option<PluginInstallElicitationTelemetryMetadata> {
|
||||
let EventMsg::ElicitationRequest(ElicitationRequestEvent { request, .. }) = event else {
|
||||
return None;
|
||||
};
|
||||
let codex_protocol::approvals::ElicitationRequest::Form {
|
||||
meta: Some(Value::Object(meta)),
|
||||
..
|
||||
} = request
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY)
|
||||
!= Some(MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION)
|
||||
|| metadata_str(meta, TOOL_SUGGESTION_ACTION_KEY) != Some(TOOL_SUGGESTION_ACTION_INSTALL)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PluginInstallElicitationTelemetryMetadata {
|
||||
tool_type: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_TYPE_KEY)?,
|
||||
tool_id: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_ID_KEY)?,
|
||||
tool_name: metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn mcp_elicitation_request_id(id: &RequestId) -> String {
|
||||
match id {
|
||||
rmcp::model::NumberOrString::String(value) => value.to_string(),
|
||||
|
||||
@@ -96,6 +96,63 @@ fn guardian_elicitation_review_request_defaults_missing_tool_params() {
|
||||
assert_eq!(arguments, Some(json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestion() {
|
||||
let event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
id: codex_protocol::mcp::RequestId::String("request-1".to_string()),
|
||||
request: codex_protocol::approvals::ElicitationRequest::Form {
|
||||
meta: Some(json!({
|
||||
"codex_approval_kind": "tool_suggestion",
|
||||
"suggest_type": "install",
|
||||
"tool_type": "plugin",
|
||||
"tool_id": "slack@openai-curated",
|
||||
"tool_name": "Slack",
|
||||
})),
|
||||
message: "Install Slack?".to_string(),
|
||||
requested_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
plugin_install_elicitation_telemetry_metadata(&event),
|
||||
Some(PluginInstallElicitationTelemetryMetadata {
|
||||
tool_type: "plugin".to_string(),
|
||||
tool_id: "slack@openai-curated".to_string(),
|
||||
tool_name: "Slack".to_string(),
|
||||
})
|
||||
);
|
||||
|
||||
let enable_event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
id: codex_protocol::mcp::RequestId::String("request-2".to_string()),
|
||||
request: codex_protocol::approvals::ElicitationRequest::Form {
|
||||
meta: Some(json!({
|
||||
"codex_approval_kind": "tool_suggestion",
|
||||
"suggest_type": "enable",
|
||||
"tool_type": "plugin",
|
||||
"tool_id": "slack@openai-curated",
|
||||
"tool_name": "Slack",
|
||||
})),
|
||||
message: "Enable Slack?".to_string(),
|
||||
requested_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
plugin_install_elicitation_telemetry_metadata(&enable_event),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_elicitation_review_request_requires_opt_in() {
|
||||
let request = form_request(meta(json!({
|
||||
|
||||
+159
-297
@@ -10,8 +10,6 @@ use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::Mailbox;
|
||||
use crate::agent::MailboxReceiver;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::attestation::AttestationProvider;
|
||||
@@ -63,7 +61,7 @@ use codex_login::CodexAuth;
|
||||
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::McpRuntimeEnvironment;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::codex_apps_tools_cache_key;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
@@ -74,13 +72,14 @@ use codex_otel::current_span_trace_id;
|
||||
use codex_otel::current_span_w3c_trace_context;
|
||||
use codex_otel::set_parent_from_w3c_trace_context;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
@@ -95,6 +94,7 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::SandboxEnforcement;
|
||||
use codex_protocol::models::format_allow_prefixes;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
@@ -173,6 +173,7 @@ use crate::compact::collect_user_messages;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::config::PermissionProfileSnapshot;
|
||||
use crate::config::PermissionProfileState;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::config::resolve_web_search_mode_for_turn;
|
||||
@@ -192,6 +193,7 @@ use codex_protocol::exec_output::StreamOutput;
|
||||
|
||||
mod config_lock;
|
||||
mod handlers;
|
||||
mod input_queue;
|
||||
mod mcp;
|
||||
mod multi_agents;
|
||||
mod review;
|
||||
@@ -205,6 +207,8 @@ use self::config_lock::validate_config_lock_if_configured;
|
||||
#[cfg(test)]
|
||||
use self::handlers::submission_dispatch_span;
|
||||
use self::handlers::submission_loop;
|
||||
pub(crate) use self::input_queue::TurnInput;
|
||||
pub(crate) use self::input_queue::TurnInputQueue;
|
||||
use self::review::spawn_review_thread;
|
||||
use self::session::AppServerClientMetadata;
|
||||
use self::session::Session;
|
||||
@@ -214,8 +218,6 @@ pub(crate) use self::session::SessionSettingsUpdate;
|
||||
use self::turn::AssistantMessageStreamParsers;
|
||||
#[cfg(test)]
|
||||
use self::turn::collect_explicit_app_ids_from_skill_items;
|
||||
#[cfg(test)]
|
||||
use self::turn::filter_connectors_for_input;
|
||||
use self::turn::realtime_text_for_event;
|
||||
use self::turn_context::TurnContext;
|
||||
use self::turn_context::TurnSkillsContext;
|
||||
@@ -288,8 +290,7 @@ use crate::rollout::map_session_init_error;
|
||||
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
|
||||
use crate::shell;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::MailboxDeliveryPhase;
|
||||
use crate::state::AutoCompactWindowSnapshot;
|
||||
use crate::state::PendingRequestPermissions;
|
||||
use crate::state::SessionServices;
|
||||
use crate::state::SessionState;
|
||||
@@ -319,7 +320,6 @@ use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
@@ -358,8 +358,8 @@ use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolEnvironmentMode;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::ToolsConfigParams;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use codex_tools::shell_command_backend_for_features;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
#[cfg(test)]
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
@@ -479,20 +479,6 @@ impl Codex {
|
||||
} = args;
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
let fs = environment_selections.primary_filesystem();
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
|
||||
let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await;
|
||||
|
||||
for err in &loaded_skills.errors {
|
||||
error!(
|
||||
"failed to load skill {}: {}",
|
||||
err.path.display(),
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source
|
||||
&& depth >= config.agent_max_depth
|
||||
@@ -503,9 +489,14 @@ impl Codex {
|
||||
}
|
||||
|
||||
let primary_environment = environment_selections.primary_environment();
|
||||
let mut user_instruction_warnings = Vec::new();
|
||||
let user_instructions = AgentsMdManager::new(&config)
|
||||
.user_instructions(primary_environment.as_deref())
|
||||
.user_instructions(
|
||||
primary_environment.as_deref(),
|
||||
&mut user_instruction_warnings,
|
||||
)
|
||||
.await;
|
||||
config.startup_warnings.extend(user_instruction_warnings);
|
||||
|
||||
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
|
||||
// Guardian review should rely on the built-in shell safety checks,
|
||||
@@ -597,14 +588,10 @@ impl Codex {
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let account_plan_type = auth_manager
|
||||
.auth_cached()
|
||||
.and_then(|auth| auth.account_plan_type());
|
||||
let service_tier = get_service_tier(
|
||||
config.service_tier.clone(),
|
||||
config.notices.fast_default_opt_out.unwrap_or(false),
|
||||
account_plan_type,
|
||||
config.features.enabled(Feature::FastMode),
|
||||
&model_info,
|
||||
);
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
@@ -806,17 +793,16 @@ impl Codex {
|
||||
|
||||
fn get_service_tier(
|
||||
configured_service_tier: Option<String>,
|
||||
fast_default_opt_out: bool,
|
||||
account_plan_type: Option<AccountPlanType>,
|
||||
fast_mode_enabled: bool,
|
||||
model_info: &ModelInfo,
|
||||
) -> Option<String> {
|
||||
if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled {
|
||||
return configured_service_tier;
|
||||
if !fast_mode_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
account_plan_type
|
||||
.is_some_and(is_enterprise_default_service_tier_plan)
|
||||
.then_some(ServiceTier::Fast.request_value().to_string())
|
||||
configured_service_tier.filter(|service_tier| {
|
||||
service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE
|
||||
|| model_info.supports_service_tier(service_tier)
|
||||
})
|
||||
}
|
||||
|
||||
fn session_permission_profile_state_from_config(
|
||||
@@ -825,12 +811,6 @@ fn session_permission_profile_state_from_config(
|
||||
Ok(config.permissions.permission_profile_state().clone())
|
||||
}
|
||||
|
||||
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
|
||||
plan_type == AccountPlanType::Enterprise
|
||||
|| plan_type.is_business_like()
|
||||
|| plan_type.is_team_like()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
|
||||
futures::future::ready(()).boxed().shared()
|
||||
@@ -898,22 +878,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec<String> {
|
||||
if !self.features.enabled(Feature::MultiAgentV2) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let state = self.state.lock().await;
|
||||
let config = &state.session_configuration.original_config_do_not_use;
|
||||
[
|
||||
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
|
||||
config.multi_agent_v2.subagent_usage_hint_text.clone(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn managed_network_proxy_active_for_permission_profile(
|
||||
permission_profile: &PermissionProfile,
|
||||
) -> bool {
|
||||
@@ -994,9 +958,6 @@ impl Session {
|
||||
}
|
||||
|
||||
async fn refresh_managed_network_proxy_for_current_permission_profile(&self) {
|
||||
let Some(started_proxy) = self.services.network_proxy.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Ok(_refresh_guard) = self.managed_network_proxy_refresh_lock.acquire().await else {
|
||||
error!("managed network proxy refresh semaphore closed");
|
||||
return;
|
||||
@@ -1010,7 +971,9 @@ impl Session {
|
||||
.permissions
|
||||
.network
|
||||
.as_ref()
|
||||
.cloned()
|
||||
else {
|
||||
self.services.network_proxy.store(None);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1033,8 +996,36 @@ impl Session {
|
||||
spec
|
||||
}
|
||||
};
|
||||
if let Err(err) = spec.apply_to_started_proxy(started_proxy).await {
|
||||
warn!("failed to refresh managed network proxy for sandbox change: {err}");
|
||||
if let Some(started_proxy) = self.services.network_proxy.load_full() {
|
||||
if let Err(err) = spec.apply_to_started_proxy(started_proxy.as_ref()).await {
|
||||
warn!("failed to refresh managed network proxy for sandbox change: {err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match Self::start_managed_network_proxy(
|
||||
&spec,
|
||||
current_exec_policy.as_ref(),
|
||||
&session_configuration.permission_profile(),
|
||||
/*network_policy_decider*/ None,
|
||||
self.services
|
||||
.managed_network_requirements_configured
|
||||
.then(|| {
|
||||
build_blocked_request_observer(Arc::clone(&self.services.network_approval))
|
||||
}),
|
||||
self.services.managed_network_requirements_configured,
|
||||
self.services.network_proxy_audit_metadata.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((started_proxy, _session_network_proxy)) => {
|
||||
self.services
|
||||
.network_proxy
|
||||
.store(Some(Arc::new(started_proxy)));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to start managed network proxy for sandbox change: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,6 +1104,7 @@ impl Session {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
/*mirror_user_text_to_realtime*/ None,
|
||||
)
|
||||
@@ -1124,6 +1116,11 @@ impl Session {
|
||||
state.get_total_token_usage(state.server_reasoning_included())
|
||||
}
|
||||
|
||||
pub(crate) async fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
|
||||
let state = self.state.lock().await;
|
||||
state.auto_compact_window_snapshot()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown {
|
||||
let state = self.state.lock().await;
|
||||
state.history.get_total_token_usage_breakdown()
|
||||
@@ -1282,11 +1279,41 @@ impl Session {
|
||||
reconstructed_rollout.reference_context_item,
|
||||
)
|
||||
.await;
|
||||
let prefix_tokens = if matches!(
|
||||
turn_context.config.model_auto_compact_token_limit_scope,
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix
|
||||
) {
|
||||
let history = self.clone_history().await;
|
||||
let base_instructions = self.get_base_instructions().await;
|
||||
history.estimate_token_count_with_base_instructions(&base_instructions)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(prefix_tokens) = prefix_tokens {
|
||||
self.set_auto_compact_window_estimated_prefill_for_scope(turn_context, prefix_tokens)
|
||||
.await;
|
||||
}
|
||||
self.set_previous_turn_settings(previous_turn_settings.clone())
|
||||
.await;
|
||||
previous_turn_settings
|
||||
}
|
||||
|
||||
async fn set_auto_compact_window_estimated_prefill_for_scope(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
tokens: i64,
|
||||
) {
|
||||
if !matches!(
|
||||
turn_context.config.model_auto_compact_token_limit_scope,
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_auto_compact_window_estimated_prefill(tokens);
|
||||
}
|
||||
|
||||
fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option<TokenUsageInfo> {
|
||||
rollout_items.iter().rev().find_map(|item| match item {
|
||||
RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(),
|
||||
@@ -1402,12 +1429,15 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_settings(
|
||||
pub(crate) async fn preview_settings(
|
||||
&self,
|
||||
updates: &SessionSettingsUpdate,
|
||||
) -> ConstraintResult<()> {
|
||||
) -> ConstraintResult<ThreadConfigSnapshot> {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.apply(updates).map(|_| ())
|
||||
state
|
||||
.session_configuration
|
||||
.apply(updates)
|
||||
.map(|configuration| configuration.thread_config_snapshot())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_session_startup_prewarm(
|
||||
@@ -1820,7 +1850,8 @@ impl Session {
|
||||
let active = self.active_turn.lock().await;
|
||||
active
|
||||
.as_ref()
|
||||
.and_then(|turn| turn.tasks.get(sub_id))
|
||||
.and_then(|turn| turn.task.as_ref())
|
||||
.filter(|task| task.turn_context.sub_id == sub_id)
|
||||
.map(|task| Arc::clone(&task.turn_context))
|
||||
}
|
||||
|
||||
@@ -1828,7 +1859,7 @@ impl Session {
|
||||
&self,
|
||||
) -> Option<(Arc<TurnContext>, CancellationToken)> {
|
||||
let active = self.active_turn.lock().await;
|
||||
let (_, task) = active.as_ref()?.tasks.first()?;
|
||||
let task = active.as_ref()?.task.as_ref()?;
|
||||
Some((
|
||||
Arc::clone(&task.turn_context),
|
||||
task.cancellation_token.child_token(),
|
||||
@@ -1889,7 +1920,7 @@ impl Session {
|
||||
let execpolicy_amendment =
|
||||
execpolicy_network_rule_amendment(amendment, network_approval_context, &host);
|
||||
|
||||
if let Some(started_network_proxy) = self.services.network_proxy.as_ref() {
|
||||
if let Some(started_network_proxy) = self.services.network_proxy.load_full() {
|
||||
let proxy = started_network_proxy.proxy();
|
||||
match amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => proxy
|
||||
@@ -2600,8 +2631,11 @@ impl Session {
|
||||
reference_context_item: Option<TurnContextItem>,
|
||||
compacted_item: CompactedItem,
|
||||
) {
|
||||
self.replace_history(items, reference_context_item.clone())
|
||||
.await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.replace_history(items, reference_context_item.clone());
|
||||
state.start_next_auto_compact_window();
|
||||
}
|
||||
|
||||
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
|
||||
.await;
|
||||
@@ -2609,6 +2643,10 @@ impl Session {
|
||||
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)])
|
||||
.await;
|
||||
}
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
|
||||
}
|
||||
self.services.model_client.advance_window_generation();
|
||||
}
|
||||
|
||||
@@ -2708,14 +2746,6 @@ impl Session {
|
||||
{
|
||||
developer_sections.push(developer_instructions.to_string());
|
||||
}
|
||||
// Add developer instructions for memories.
|
||||
if turn_context.features.enabled(Feature::MemoryTool)
|
||||
&& turn_context.config.memories.use_memories
|
||||
&& let Some(memory_prompt) =
|
||||
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
|
||||
{
|
||||
developer_sections.push(memory_prompt);
|
||||
}
|
||||
// Add developer instructions from collaboration_mode if they exist and are non-empty
|
||||
if turn_context.config.include_collaboration_mode_instructions
|
||||
&& let Some(collab_instructions) =
|
||||
@@ -2967,16 +2997,24 @@ impl Session {
|
||||
let mut state = self.state.lock().await;
|
||||
state
|
||||
.update_token_info_from_usage(token_usage, turn_context.model_context_window());
|
||||
if matches!(
|
||||
turn_context.config.model_auto_compact_token_limit_scope,
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix
|
||||
) {
|
||||
state.ensure_auto_compact_window_server_prefill_from_usage(token_usage);
|
||||
}
|
||||
state.token_info()
|
||||
};
|
||||
if let Some(token_info) = token_info.as_ref() {
|
||||
for contributor in self.services.extensions.token_usage_contributors() {
|
||||
contributor.on_token_usage(
|
||||
&self.services.session_extension_data,
|
||||
&self.services.thread_extension_data,
|
||||
turn_context.extension_data.as_ref(),
|
||||
token_info,
|
||||
);
|
||||
contributor
|
||||
.on_token_usage(
|
||||
&self.services.session_extension_data,
|
||||
&self.services.thread_extension_data,
|
||||
turn_context.extension_data.as_ref(),
|
||||
token_info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3012,6 +3050,11 @@ impl Session {
|
||||
|
||||
state.set_token_info(Some(info));
|
||||
}
|
||||
self.set_auto_compact_window_estimated_prefill_for_scope(
|
||||
turn_context,
|
||||
estimated_total_tokens,
|
||||
)
|
||||
.await;
|
||||
self.send_token_count_event(turn_context).await;
|
||||
}
|
||||
|
||||
@@ -3044,16 +3087,6 @@ impl Session {
|
||||
state.record_mcp_dependency_prompted(names);
|
||||
}
|
||||
|
||||
pub async fn dependency_env(&self) -> HashMap<String, String> {
|
||||
let state = self.state.lock().await;
|
||||
state.dependency_env()
|
||||
}
|
||||
|
||||
pub async fn set_dependency_env(&self, values: HashMap<String, String>) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_dependency_env(values);
|
||||
}
|
||||
|
||||
pub(crate) async fn set_server_reasoning_included(&self, included: bool) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_server_reasoning_included(included);
|
||||
@@ -3096,11 +3129,11 @@ impl Session {
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
input: &[UserInput],
|
||||
response_item: ResponseItem,
|
||||
) {
|
||||
// Persist the user message to history, but emit the turn item from `UserInput` so
|
||||
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
|
||||
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
|
||||
let response_item = ResponseItem::from(ResponseInputItem::from(input.to_vec()));
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
let turn_item = TurnItem::UserMessage(UserMessageItem::new(input));
|
||||
@@ -3140,18 +3173,15 @@ impl Session {
|
||||
expected_turn_id: Option<&str>,
|
||||
responsesapi_client_metadata: Option<HashMap<String, String>>,
|
||||
) -> Result<String, SteerInputError> {
|
||||
if input.is_empty() {
|
||||
return Err(SteerInputError::EmptyInput);
|
||||
}
|
||||
|
||||
let mut active = self.active_turn.lock().await;
|
||||
let Some(active_turn) = active.as_mut() else {
|
||||
return Err(SteerInputError::NoActiveTurn(input));
|
||||
};
|
||||
|
||||
let Some((active_turn_id, _)) = active_turn.tasks.first() else {
|
||||
let Some(active_task) = active_turn.task.as_ref() else {
|
||||
return Err(SteerInputError::NoActiveTurn(input));
|
||||
};
|
||||
let active_turn_id = &active_task.turn_context.sub_id;
|
||||
|
||||
if let Some(expected_turn_id) = expected_turn_id
|
||||
&& expected_turn_id != active_turn_id
|
||||
@@ -3162,223 +3192,64 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
match active_turn.tasks.first().map(|(_, task)| task.kind) {
|
||||
Some(crate::state::TaskKind::Regular) => {}
|
||||
Some(crate::state::TaskKind::Review) => {
|
||||
match active_task.kind {
|
||||
crate::state::TaskKind::Regular => {}
|
||||
crate::state::TaskKind::Review => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
});
|
||||
}
|
||||
Some(crate::state::TaskKind::Compact) => {
|
||||
crate::state::TaskKind::Compact => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
});
|
||||
}
|
||||
None => return Err(SteerInputError::NoActiveTurn(input)),
|
||||
}
|
||||
|
||||
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata
|
||||
&& let Some((_, active_task)) = active_turn.tasks.first()
|
||||
{
|
||||
if input.is_empty() {
|
||||
return Err(SteerInputError::EmptyInput);
|
||||
}
|
||||
|
||||
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
|
||||
active_task
|
||||
.turn_context
|
||||
.turn_metadata_state
|
||||
.set_responsesapi_client_metadata(responsesapi_client_metadata);
|
||||
}
|
||||
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.push_pending_input(input.into());
|
||||
turn_state.accept_mailbox_delivery_for_current_turn();
|
||||
self.input_queue
|
||||
.push_pending_input_and_accept_mailbox_delivery_for_turn_state(
|
||||
active_turn.turn_state.as_ref(),
|
||||
TurnInput::UserInput(input),
|
||||
)
|
||||
.await;
|
||||
Ok(active_turn_id.clone())
|
||||
}
|
||||
|
||||
/// Returns the input if there was no task running to inject into.
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn inject_response_items(
|
||||
&self,
|
||||
input: Vec<ResponseInputItem>,
|
||||
) -> Result<(), Vec<ResponseInputItem>> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
for item in input {
|
||||
ts.push_pending_input(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(input),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn defer_mailbox_delivery_to_next_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
if turn_state.has_pending_input() {
|
||||
return;
|
||||
}
|
||||
turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn);
|
||||
}
|
||||
|
||||
pub(crate) async fn accept_mailbox_delivery_for_current_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
turn_state
|
||||
.lock()
|
||||
self.input_queue
|
||||
.inject_response_items(&self.active_turn, input)
|
||||
.await
|
||||
.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn);
|
||||
}
|
||||
|
||||
pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let turn_state = self
|
||||
.input_queue
|
||||
.turn_state_for_sub_id(&self.active_turn, sub_id)
|
||||
.await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
turn_state.lock().await.has_memory_citation = true;
|
||||
}
|
||||
|
||||
async fn turn_state_for_sub_id(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
) -> Option<Arc<tokio::sync::Mutex<crate::state::TurnState>>> {
|
||||
let active = self.active_turn.lock().await;
|
||||
active.as_ref().and_then(|active_turn| {
|
||||
active_turn
|
||||
.tasks
|
||||
.contains_key(sub_id)
|
||||
.then(|| Arc::clone(&active_turn.turn_state))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_mailbox_seq(&self) -> watch::Receiver<u64> {
|
||||
self.mailbox.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_mailbox_communication(&self, communication: InterAgentCommunication) {
|
||||
self.mailbox.send(communication);
|
||||
}
|
||||
|
||||
pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool {
|
||||
self.mailbox_rx.lock().await.has_pending_trigger_turn()
|
||||
}
|
||||
|
||||
pub(crate) async fn has_pending_mailbox_items(&self) -> bool {
|
||||
self.mailbox_rx.lock().await.has_pending()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn prepend_pending_input(&self, input: Vec<ResponseInputItem>) -> Result<(), ()> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.prepend_pending_input(input);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||||
let (pending_input, accepts_mailbox_delivery) = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
(
|
||||
ts.take_pending_input(),
|
||||
ts.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), true),
|
||||
}
|
||||
};
|
||||
if !accepts_mailbox_delivery {
|
||||
return pending_input;
|
||||
}
|
||||
let mailbox_items = {
|
||||
let mut mailbox_rx = self.mailbox_rx.lock().await;
|
||||
mailbox_rx
|
||||
.drain()
|
||||
.into_iter()
|
||||
.map(|mail| mail.to_response_input_item())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if pending_input.is_empty() {
|
||||
mailbox_items
|
||||
} else if mailbox_items.is_empty() {
|
||||
pending_input
|
||||
} else {
|
||||
let mut pending_input = pending_input;
|
||||
pending_input.extend(mailbox_items);
|
||||
pending_input
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue response items to be injected into the next active turn created for this session.
|
||||
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut idle_pending_input = self.idle_pending_input.lock().await;
|
||||
idle_pending_input.extend(items);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state reads must remain atomic"
|
||||
)]
|
||||
pub async fn has_pending_input(&self) -> bool {
|
||||
let (has_turn_pending_input, accepts_mailbox_delivery) = {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
(
|
||||
ts.has_pending_input(),
|
||||
ts.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (false, true),
|
||||
}
|
||||
};
|
||||
if has_turn_pending_input {
|
||||
return true;
|
||||
}
|
||||
if !accepts_mailbox_delivery {
|
||||
return false;
|
||||
}
|
||||
self.has_pending_mailbox_items().await
|
||||
}
|
||||
|
||||
pub async fn interrupt_task(self: &Arc<Self>) {
|
||||
info!("interrupt received: abort current task, if any");
|
||||
let had_active_turn = self.active_turn.lock().await.is_some();
|
||||
// Even without an active task, interrupt handling pauses any active goal.
|
||||
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
if !had_active_turn {
|
||||
self.cancel_mcp_startup().await;
|
||||
@@ -3456,8 +3327,6 @@ pub(crate) fn emit_subagent_session_started(
|
||||
});
|
||||
}
|
||||
|
||||
use codex_memories_read::build_memory_tool_developer_instructions;
|
||||
|
||||
/// Builds the hook engine for one config snapshot, including any enabled plugin hooks.
|
||||
async fn build_hooks_for_config(
|
||||
config: &Config,
|
||||
@@ -3467,17 +3336,10 @@ async fn build_hooks_for_config(
|
||||
let mut hook_shell_argv = user_shell.derive_exec_args("", /*use_login_shell*/ false);
|
||||
let hook_shell_program = hook_shell_argv.remove(0);
|
||||
let _ = hook_shell_argv.pop();
|
||||
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
|
||||
let (plugin_hook_sources, plugin_hook_load_warnings) = if plugin_hooks_enabled {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
(
|
||||
plugin_outcome.effective_plugin_hook_sources(),
|
||||
plugin_outcome.effective_plugin_hook_warnings(),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
let plugin_hook_sources = plugin_outcome.effective_plugin_hook_sources();
|
||||
let plugin_hook_load_warnings = plugin_outcome.effective_plugin_hook_warnings();
|
||||
Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
feature_enabled: config.features.enabled(Feature::CodexHooks),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::turn_context::image_generation_tool_auth_allowed;
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
@@ -23,60 +22,21 @@ pub(super) async fn spawn_review_thread(
|
||||
let mut review_features = sess.features.clone();
|
||||
let _ = review_features.disable(Feature::WebSearchRequest);
|
||||
let _ = review_features.disable(Feature::WebSearchCached);
|
||||
let _ = review_features.disable(Feature::Goals);
|
||||
let review_web_search_mode = WebSearchMode::Disabled;
|
||||
let goal_tools_supported = !config.ephemeral && parent_turn_context.tools_config.goal_tools;
|
||||
let provider_capabilities = parent_turn_context.provider.capabilities();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &review_model_info,
|
||||
available_models: &sess
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await,
|
||||
features: &review_features,
|
||||
image_generation_tool_auth_allowed: image_generation_tool_auth_allowed(Some(
|
||||
sess.services.auth_manager.as_ref(),
|
||||
)),
|
||||
web_search_mode: Some(review_web_search_mode),
|
||||
session_source: parent_turn_context.session_source.clone(),
|
||||
permission_profile: &parent_turn_context.permission_profile,
|
||||
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
|
||||
})
|
||||
.with_namespace_tools_capability(provider_capabilities.namespace_tools)
|
||||
.with_image_generation_capability(provider_capabilities.image_generation)
|
||||
.with_web_search_capability(provider_capabilities.web_search)
|
||||
.with_unified_exec_shell_mode_for_session(
|
||||
let goal_tools_supported = !config.ephemeral && parent_turn_context.goal_tools_enabled();
|
||||
let available_models = sess
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
let shell_command_backend = shell_command_backend_for_features(review_features.get());
|
||||
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
|
||||
shell_command_backend,
|
||||
crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()),
|
||||
sess.services.shell_zsh_path.as_ref(),
|
||||
sess.services.main_execve_wrapper_exe.as_ref(),
|
||||
)
|
||||
.with_web_search_config(/*web_search_config*/ None)
|
||||
.with_allow_login_shell(config.permissions.allow_login_shell)
|
||||
.with_environment_mode(parent_turn_context.tools_config.environment_mode)
|
||||
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(goal_tools_supported)
|
||||
.with_max_concurrent_threads_per_session(config.agent_max_threads)
|
||||
.with_wait_agent_min_timeout_ms(
|
||||
review_features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.min_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_max_timeout_ms(
|
||||
review_features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.max_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_default_timeout_ms(
|
||||
review_features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.default_wait_timeout_ms),
|
||||
)
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&config.agent_roles,
|
||||
));
|
||||
);
|
||||
|
||||
let review_prompt = resolved.prompt.clone();
|
||||
let provider = parent_turn_context.provider.clone();
|
||||
@@ -138,8 +98,10 @@ pub(super) async fn spawn_review_thread(
|
||||
session_source,
|
||||
thread_source: parent_turn_context.thread_source,
|
||||
environments: parent_turn_context.environments.clone(),
|
||||
tools_config,
|
||||
features: parent_turn_context.features.clone(),
|
||||
available_models,
|
||||
unified_exec_shell_mode,
|
||||
goal_tools_supported,
|
||||
features: review_features,
|
||||
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
|
||||
current_date: parent_turn_context.current_date.clone(),
|
||||
timezone: parent_turn_context.timezone.clone(),
|
||||
|
||||
@@ -59,7 +59,6 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -74,11 +73,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)];
|
||||
|
||||
@@ -101,7 +96,6 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
let previous_model = "previous-rollout-model";
|
||||
let mut previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -116,11 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -132,6 +122,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -143,6 +134,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -198,6 +190,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -209,6 +202,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -226,6 +220,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: rolled_back_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -237,6 +232,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(rolled_back_context_item),
|
||||
@@ -296,6 +292,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -307,6 +304,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -324,6 +322,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: incomplete_turn_id,
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -335,6 +334,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::ResponseItem(turn_two_user),
|
||||
@@ -386,6 +386,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -397,6 +398,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -414,6 +416,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: second_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -425,6 +428,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::ResponseItem(turn_two_user),
|
||||
@@ -441,6 +445,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: standalone_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -504,6 +509,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -515,6 +521,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -532,6 +539,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: assistant_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -592,6 +600,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: only_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -603,6 +612,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(only_context_item),
|
||||
@@ -644,6 +654,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: user_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -655,6 +666,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -671,6 +683,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: standalone_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -716,6 +729,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: previous_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -727,6 +741,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item.clone()),
|
||||
@@ -742,6 +757,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: incomplete_turn_id,
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -753,6 +769,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
@@ -873,6 +890,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: current_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -884,6 +902,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(current_context_item),
|
||||
@@ -912,7 +931,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -927,11 +945,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -941,6 +955,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: previous_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -952,6 +967,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
// Compaction clears baseline until a later TurnContextItem re-establishes it.
|
||||
@@ -991,7 +1007,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
.expect("serialize seeded reference context item"),
|
||||
serde_json::to_value(Some(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1006,11 +1021,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
}))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
@@ -1023,7 +1034,6 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1038,11 +1048,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1054,6 +1060,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: previous_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1065,6 +1072,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1080,6 +1088,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: aborted_turn_id,
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1091,6 +1100,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(
|
||||
@@ -1139,7 +1149,6 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
let unmatched_abort_turn_id = "other-turn".to_string();
|
||||
let current_context_item = TurnContextItem {
|
||||
turn_id: Some(current_turn_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1154,17 +1163,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
|
||||
let rollout_items = vec![
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: previous_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1176,6 +1182,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1191,6 +1198,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: current_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1202,6 +1210,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(
|
||||
@@ -1254,7 +1263,6 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1269,11 +1277,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1285,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: previous_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1296,6 +1301,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1311,6 +1317,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: incomplete_turn_id,
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1322,6 +1329,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
@@ -1361,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: current_turn_id,
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1372,6 +1381,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(current_context_item.clone()),
|
||||
@@ -1407,7 +1417,6 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1422,11 +1431,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1439,6 +1444,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: previous_turn_id.clone(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1450,6 +1456,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1465,6 +1472,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: compacted_incomplete_turn_id,
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
@@ -1476,6 +1484,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
@@ -1487,6 +1496,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: replacing_turn_id,
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user