mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Merge remote-tracking branch 'origin/main' into rreichel3/yolo-only-enforce-blocklist-20260324
This commit is contained in:
@@ -547,7 +547,7 @@ jobs:
|
||||
tests:
|
||||
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }}
|
||||
runs-on: ${{ matrix.runs_on || matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: ${{ matrix.runner == 'windows-arm64' && 35 || 30 }}
|
||||
needs: changed
|
||||
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
|
||||
defaults:
|
||||
|
||||
Generated
+20
@@ -1448,6 +1448,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-rmcp-client",
|
||||
"codex-sandboxing",
|
||||
"codex-shell-command",
|
||||
"codex-state",
|
||||
"codex-utils-absolute-path",
|
||||
@@ -1678,6 +1679,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-responses-api-proxy",
|
||||
"codex-rmcp-client",
|
||||
"codex-sandboxing",
|
||||
"codex-state",
|
||||
"codex-stdio-to-uds",
|
||||
"codex-terminal-detection",
|
||||
@@ -1885,6 +1887,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-rmcp-client",
|
||||
"codex-sandboxing",
|
||||
"codex-secrets",
|
||||
"codex-shell-command",
|
||||
"codex-shell-escalation",
|
||||
@@ -2462,6 +2465,23 @@ dependencies = [
|
||||
"which 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-sandboxing"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-network-proxy",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-secrets"
|
||||
version = "0.0.0"
|
||||
|
||||
+3
-1
@@ -42,6 +42,7 @@ members = [
|
||||
"protocol",
|
||||
"rmcp-client",
|
||||
"responses-api-proxy",
|
||||
"sandboxing",
|
||||
"stdio-to-uds",
|
||||
"otel",
|
||||
"tui",
|
||||
@@ -130,6 +131,7 @@ codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
codex-rmcp-client = { path = "rmcp-client" }
|
||||
codex-sandboxing = { path = "sandboxing" }
|
||||
codex-secrets = { path = "secrets" }
|
||||
codex-shell-command = { path = "shell-command" }
|
||||
codex-shell-escalation = { path = "shell-escalation" }
|
||||
@@ -288,7 +290,7 @@ supports-color = "3.0.2"
|
||||
syntect = "5"
|
||||
sys-locale = "0.3.2"
|
||||
tempfile = "3.23.0"
|
||||
tar = "0.4.44"
|
||||
tar = "0.4.45"
|
||||
test-log = "0.2.19"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = "2.0.17"
|
||||
|
||||
@@ -1180,6 +1180,7 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -7994,6 +7994,7 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -4698,6 +4698,7 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"definitions": {
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"definitions": {
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookEventName = "sessionStart" | "userPromptSubmit" | "stop";
|
||||
export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop";
|
||||
|
||||
@@ -74,6 +74,8 @@ pub struct ThreadHistoryBuilder {
|
||||
turns: Vec<Turn>,
|
||||
current_turn: Option<PendingTurn>,
|
||||
next_item_index: i64,
|
||||
current_rollout_index: usize,
|
||||
next_rollout_index: usize,
|
||||
}
|
||||
|
||||
impl Default for ThreadHistoryBuilder {
|
||||
@@ -88,6 +90,8 @@ impl ThreadHistoryBuilder {
|
||||
turns: Vec::new(),
|
||||
current_turn: None,
|
||||
next_item_index: 1,
|
||||
current_rollout_index: 0,
|
||||
next_rollout_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +115,19 @@ impl ThreadHistoryBuilder {
|
||||
self.current_turn.is_some()
|
||||
}
|
||||
|
||||
pub fn active_turn_id_if_explicit(&self) -> Option<String> {
|
||||
self.current_turn
|
||||
.as_ref()
|
||||
.filter(|turn| turn.opened_explicitly)
|
||||
.map(|turn| turn.id.clone())
|
||||
}
|
||||
|
||||
pub fn active_turn_start_index(&self) -> Option<usize> {
|
||||
self.current_turn
|
||||
.as_ref()
|
||||
.map(|turn| turn.rollout_start_index)
|
||||
}
|
||||
|
||||
/// Shared reducer for persisted rollout replay and in-memory current-turn
|
||||
/// tracking used by running thread resume/rejoin.
|
||||
///
|
||||
@@ -182,6 +199,8 @@ impl ThreadHistoryBuilder {
|
||||
}
|
||||
|
||||
pub fn handle_rollout_item(&mut self, item: &RolloutItem) {
|
||||
self.current_rollout_index = self.next_rollout_index;
|
||||
self.next_rollout_index += 1;
|
||||
match item {
|
||||
RolloutItem::EventMsg(event) => self.handle_event(event),
|
||||
RolloutItem::Compacted(payload) => self.handle_compacted(payload),
|
||||
@@ -974,6 +993,7 @@ impl ThreadHistoryBuilder {
|
||||
status: TurnStatus::Completed,
|
||||
opened_explicitly: false,
|
||||
saw_compaction: false,
|
||||
rollout_start_index: self.current_rollout_index,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,6 +1157,8 @@ struct PendingTurn {
|
||||
/// True when this turn includes a persisted `RolloutItem::Compacted`, which
|
||||
/// should keep the turn from being dropped even without normal items.
|
||||
saw_compaction: bool,
|
||||
/// Index of the rollout item that opened this turn during replay.
|
||||
rollout_start_index: usize,
|
||||
}
|
||||
|
||||
impl PendingTurn {
|
||||
|
||||
@@ -377,7 +377,7 @@ v2_enum_from_core!(
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum HookEventName from CoreHookEventName {
|
||||
SessionStart, UserPromptSubmit, Stop
|
||||
PreToolUse, SessionStart, UserPromptSubmit, Stop
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ codex-protocol = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-feedback = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-json-to-toml = { workspace = true }
|
||||
|
||||
@@ -125,7 +125,7 @@ Example with notification opt-out:
|
||||
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
@@ -240,7 +240,7 @@ Example:
|
||||
{ "id": 11, "result": { "thread": { "id": "thr_123", … } } }
|
||||
```
|
||||
|
||||
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. Pass `ephemeral: true` when the fork should stay in-memory only:
|
||||
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
|
||||
|
||||
```json
|
||||
{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } }
|
||||
|
||||
@@ -110,7 +110,6 @@ use codex_core::ThreadManager;
|
||||
use codex_core::find_thread_name_by_id;
|
||||
use codex_core::review_format::format_review_findings_block;
|
||||
use codex_core::review_prompts;
|
||||
use codex_core::sandboxing::intersect_permission_profiles;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
|
||||
@@ -136,6 +135,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse;
|
||||
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
|
||||
use codex_shell_command::parse_command::shlex_join;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
@@ -183,6 +183,7 @@ use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::Cursor as RolloutCursor;
|
||||
use codex_core::ForkSnapshot;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::SessionMeta;
|
||||
@@ -4039,7 +4040,7 @@ impl CodexMessageProcessor {
|
||||
} = match self
|
||||
.thread_manager
|
||||
.fork_thread(
|
||||
usize::MAX,
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
rollout_path.clone(),
|
||||
persist_extended_history,
|
||||
@@ -6508,7 +6509,7 @@ impl CodexMessageProcessor {
|
||||
} = self
|
||||
.thread_manager
|
||||
.fork_thread(
|
||||
usize::MAX,
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
|
||||
@@ -35,7 +35,7 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::ORIGIN;
|
||||
|
||||
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(super) type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
"expected forked thread to include one turn"
|
||||
);
|
||||
let turn = &thread.turns[0];
|
||||
assert_eq!(turn.status, TurnStatus::Completed);
|
||||
assert_eq!(turn.status, TurnStatus::Interrupted);
|
||||
assert_eq!(turn.items.len(), 1, "expected user message item");
|
||||
match &turn.items[0] {
|
||||
ThreadItem::UserMessage { content, .. } => {
|
||||
|
||||
@@ -585,9 +585,10 @@ request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
|
||||
[mcp_servers.required_broken]
|
||||
command = "codex-definitely-not-a-real-binary"
|
||||
{required_broken_transport}
|
||||
required = true
|
||||
"#
|
||||
"#,
|
||||
required_broken_transport = broken_mcp_transport_toml()
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -615,8 +616,21 @@ request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
|
||||
[mcp_servers.optional_broken]
|
||||
command = "codex-definitely-not-a-real-binary"
|
||||
"#
|
||||
{optional_broken_transport}
|
||||
"#,
|
||||
optional_broken_transport = broken_mcp_transport_toml()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn broken_mcp_transport_toml() -> &'static str {
|
||||
r#"command = "cmd"
|
||||
args = ["/C", "exit 1"]"#
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn broken_mcp_transport_toml() -> &'static str {
|
||||
r#"command = "/bin/sh"
|
||||
args = ["-c", "exit 1"]"#
|
||||
}
|
||||
|
||||
@@ -309,14 +309,16 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
|
||||
#[cfg(windows)]
|
||||
const PATH_SEPARATOR: &str = ";";
|
||||
|
||||
let path_element = path.display();
|
||||
let updated_path_env_var = match std::env::var("PATH") {
|
||||
Ok(existing_path) => {
|
||||
format!("{path_element}{PATH_SEPARATOR}{existing_path}")
|
||||
}
|
||||
Err(_) => {
|
||||
format!("{path_element}")
|
||||
let updated_path_env_var = match std::env::var_os("PATH") {
|
||||
Some(existing_path) => {
|
||||
let mut path_env_var =
|
||||
std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len());
|
||||
path_env_var.push(path);
|
||||
path_env_var.push(PATH_SEPARATOR);
|
||||
path_env_var.push(existing_path);
|
||||
path_env_var
|
||||
}
|
||||
None => path.as_os_str().to_owned(),
|
||||
};
|
||||
|
||||
unsafe {
|
||||
|
||||
@@ -36,6 +36,7 @@ codex-mcp-server = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-responses-api-proxy = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-stdio-to-uds = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
|
||||
@@ -11,14 +11,14 @@ use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::NetworkProxyAuditMetadata;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_core::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_core::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
@@ -48,6 +48,7 @@ codex-otel = { workspace = true }
|
||||
codex-artifacts = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
|
||||
@@ -5,8 +5,10 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::event_mapping::parse_turn_item;
|
||||
use crate::find_archived_thread_path_by_id_str;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
@@ -18,7 +20,10 @@ use crate::thread_manager::ThreadManagerState;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
@@ -29,6 +34,7 @@ use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
@@ -51,6 +57,13 @@ pub(crate) struct LiveAgent {
|
||||
pub(crate) status: AgentStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct ListedAgent {
|
||||
pub(crate) agent_name: String,
|
||||
pub(crate) agent_status: AgentStatus,
|
||||
pub(crate) last_task_message: Option<String>,
|
||||
}
|
||||
|
||||
fn default_agent_nickname_list() -> Vec<&'static str> {
|
||||
AGENT_NAMES
|
||||
.lines()
|
||||
@@ -266,6 +279,7 @@ impl AgentControl {
|
||||
new_thread.thread_id,
|
||||
notification_source,
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
|
||||
Ok(LiveAgent {
|
||||
@@ -437,6 +451,7 @@ impl AgentControl {
|
||||
resumed_thread.thread_id,
|
||||
Some(notification_source.clone()),
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
self.persist_thread_spawn_edge_for_source(
|
||||
resumed_thread.thread.as_ref(),
|
||||
@@ -454,21 +469,28 @@ impl AgentControl {
|
||||
agent_id: ThreadId,
|
||||
items: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
let last_task_message = render_input_preview(&items);
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
let result = self
|
||||
.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
self.state
|
||||
.update_last_task_message(agent_id, last_task_message);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
|
||||
@@ -492,15 +514,22 @@ impl AgentControl {
|
||||
agent_id: ThreadId,
|
||||
communication: InterAgentCommunication,
|
||||
) -> CodexResult<String> {
|
||||
let last_task_message = communication.content.clone();
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(agent_id, Op::InterAgentCommunication { communication })
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
let result = self
|
||||
.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(agent_id, Op::InterAgentCommunication { communication })
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
self.state
|
||||
.update_last_task_message(agent_id, last_task_message);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Interrupt the current task for an existing agent thread.
|
||||
@@ -678,6 +707,70 @@ impl AgentControl {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) async fn list_agents(
|
||||
&self,
|
||||
current_session_source: &SessionSource,
|
||||
path_prefix: Option<&str>,
|
||||
) -> CodexResult<Vec<ListedAgent>> {
|
||||
let state = self.upgrade()?;
|
||||
let resolved_prefix = path_prefix
|
||||
.map(|prefix| {
|
||||
current_session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root)
|
||||
.resolve(prefix)
|
||||
.map_err(CodexErr::UnsupportedOperation)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let mut live_agents = self.state.live_agents();
|
||||
live_agents.sort_by(|left, right| {
|
||||
left.agent_path
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.cmp(right.agent_path.as_deref().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left.agent_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default()
|
||||
.cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default())
|
||||
})
|
||||
});
|
||||
|
||||
let mut agents = Vec::with_capacity(live_agents.len());
|
||||
for metadata in live_agents {
|
||||
let Some(thread_id) = metadata.agent_id else {
|
||||
continue;
|
||||
};
|
||||
if resolved_prefix
|
||||
.as_ref()
|
||||
.is_some_and(|prefix| !agent_matches_prefix(metadata.agent_path.as_ref(), prefix))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(thread) = state.get_thread(thread_id).await else {
|
||||
continue;
|
||||
};
|
||||
let agent_name = metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| thread_id.to_string());
|
||||
let last_task_message = match metadata.last_task_message.clone() {
|
||||
Some(last_task_message) => Some(last_task_message),
|
||||
None => last_task_message_for_thread(thread.as_ref()).await,
|
||||
};
|
||||
agents.push(ListedAgent {
|
||||
agent_name,
|
||||
agent_status: thread.agent_status().await,
|
||||
last_task_message,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
/// Starts a detached watcher for sub-agents spawned from another thread.
|
||||
///
|
||||
/// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and
|
||||
@@ -687,6 +780,7 @@ impl AgentControl {
|
||||
child_thread_id: ThreadId,
|
||||
session_source: Option<SessionSource>,
|
||||
child_reference: String,
|
||||
child_agent_path: Option<AgentPath>,
|
||||
) {
|
||||
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
@@ -717,6 +811,39 @@ impl AgentControl {
|
||||
let Ok(state) = control.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let child_thread = state.get_thread(child_thread_id).await.ok();
|
||||
if let Some(child_agent_path) = child_agent_path
|
||||
&& child_thread
|
||||
.as_ref()
|
||||
.map(|thread| thread.enabled(Feature::MultiAgentV2))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
let AgentStatus::Completed(Some(content)) = &status else {
|
||||
return;
|
||||
};
|
||||
let Some((parent_path, _)) = child_agent_path.as_str().rsplit_once('/') else {
|
||||
return;
|
||||
};
|
||||
let Ok(parent_agent_path) = AgentPath::try_from(parent_path) else {
|
||||
return;
|
||||
};
|
||||
let Some(parent_thread_id) = control.state.agent_id_for_path(&parent_agent_path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = control
|
||||
.send_inter_agent_communication(
|
||||
parent_thread_id,
|
||||
InterAgentCommunication::new(
|
||||
child_agent_path,
|
||||
parent_agent_path,
|
||||
Vec::new(),
|
||||
content.clone(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let Ok(parent_thread) = state.get_thread(parent_thread_id).await else {
|
||||
return;
|
||||
};
|
||||
@@ -764,6 +891,7 @@ impl AgentControl {
|
||||
agent_path,
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
last_task_message: None,
|
||||
};
|
||||
Ok((session_source, agent_metadata))
|
||||
}
|
||||
@@ -927,6 +1055,95 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option<Threa
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
|
||||
if prefix.is_root() {
|
||||
return true;
|
||||
}
|
||||
|
||||
agent_path.is_some_and(|agent_path| {
|
||||
agent_path == prefix
|
||||
|| agent_path
|
||||
.as_str()
|
||||
.strip_prefix(prefix.as_str())
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option<String> {
|
||||
let pending_input = thread.codex.session.pending_input_snapshot().await;
|
||||
if let Some(message) = pending_input
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(last_task_message_from_input_item)
|
||||
{
|
||||
return Some(message);
|
||||
}
|
||||
|
||||
let queued_input = thread
|
||||
.codex
|
||||
.session
|
||||
.queued_response_items_for_next_turn_snapshot()
|
||||
.await;
|
||||
if let Some(message) = queued_input
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(last_task_message_from_input_item)
|
||||
{
|
||||
return Some(message);
|
||||
}
|
||||
|
||||
let history = thread.codex.session.clone_history().await;
|
||||
history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(last_task_message_from_item)
|
||||
}
|
||||
|
||||
fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option<String> {
|
||||
let response_item: ResponseItem = item.clone().into();
|
||||
last_task_message_from_item(&response_item)
|
||||
}
|
||||
|
||||
fn last_task_message_from_item(item: &ResponseItem) -> Option<String> {
|
||||
if !is_user_turn_boundary(item) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else {
|
||||
return None;
|
||||
};
|
||||
Some(render_input_preview(&message.content))
|
||||
}
|
||||
ResponseItem::Message { content, .. } => match content.as_slice() {
|
||||
[ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
|
||||
serde_json::from_str::<InterAgentCommunication>(text)
|
||||
.ok()
|
||||
.map(|communication| communication.content)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_input_preview(items: &[UserInput]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
|
||||
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
|
||||
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {
|
||||
match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth),
|
||||
|
||||
@@ -11,11 +11,13 @@ use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use assert_matches::assert_matches;
|
||||
use chrono::Utc;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -27,6 +29,8 @@ use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const MULTI_AGENT_EVENTUAL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
async fn test_config_with_cli_overrides(
|
||||
@@ -125,6 +129,29 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn history_contains_assistant_inter_agent_communication(
|
||||
history_items: &[ResponseItem],
|
||||
expected: &InterAgentCommunication,
|
||||
) -> bool {
|
||||
history_items.iter().any(|item| {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
if role != "assistant" {
|
||||
return false;
|
||||
}
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
serde_json::from_str::<InterAgentCommunication>(text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
== Some(expected)
|
||||
}
|
||||
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_subagent_notification(parent_thread: &Arc<CodexThread>) -> bool {
|
||||
let wait = async {
|
||||
loop {
|
||||
@@ -937,6 +964,194 @@ async fn spawn_child_completion_notifies_parent_history() {
|
||||
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (root_thread_id, _) = harness.start_thread().await;
|
||||
let mut config = harness.config.clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
|
||||
let worker_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config.clone(),
|
||||
text_input("hello worker"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("worker spawn should succeed");
|
||||
let tester_path = worker_path.join("tester").expect("tester path");
|
||||
let tester_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config,
|
||||
text_input("hello tester"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: worker_thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(tester_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("tester spawn should succeed");
|
||||
|
||||
let tester_thread = harness
|
||||
.manager
|
||||
.get_thread(tester_thread_id)
|
||||
.await
|
||||
.expect("tester thread should exist");
|
||||
let tester_turn = tester_thread.codex.session.new_default_turn().await;
|
||||
tester_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
tester_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: tester_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let expected = InterAgentCommunication::new(
|
||||
tester_path.clone(),
|
||||
worker_path.clone(),
|
||||
Vec::new(),
|
||||
"done".to_string(),
|
||||
);
|
||||
|
||||
timeout(MULTI_AGENT_EVENTUAL_TIMEOUT, async {
|
||||
loop {
|
||||
let delivered = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.any(|(thread_id, op)| {
|
||||
thread_id == worker_thread_id
|
||||
&& matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication == expected
|
||||
)
|
||||
});
|
||||
if delivered {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("completion watcher should send inter-agent communication");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (root_thread_id, root_thread) = harness.start_thread().await;
|
||||
let mut config = harness.config.clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
|
||||
let worker_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config.clone(),
|
||||
text_input("hello worker"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("worker spawn should succeed");
|
||||
let tester_path = worker_path.join("tester").expect("tester path");
|
||||
let tester_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config,
|
||||
text_input("hello tester"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: worker_thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(tester_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("tester spawn should succeed");
|
||||
harness
|
||||
.control
|
||||
.shutdown_live_agent(worker_thread_id)
|
||||
.await
|
||||
.expect("worker shutdown should succeed");
|
||||
|
||||
let tester_thread = harness
|
||||
.manager
|
||||
.get_thread(tester_thread_id)
|
||||
.await
|
||||
.expect("tester thread should exist");
|
||||
let tester_turn = tester_thread.codex.session.new_default_turn().await;
|
||||
tester_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
tester_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: tester_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
assert!(
|
||||
!harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.any(|(thread_id, op)| {
|
||||
thread_id == worker_thread_id
|
||||
&& matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication.author == tester_path
|
||||
&& communication.recipient == worker_path
|
||||
&& communication.content == "done"
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
let root_history_items = root_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
assert!(!history_contains_assistant_inter_agent_communication(
|
||||
&root_history_items,
|
||||
&InterAgentCommunication::new(
|
||||
tester_path,
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"done".to_string(),
|
||||
)
|
||||
));
|
||||
assert!(!has_subagent_notification(&root_history_items));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_watcher_notifies_parent_when_child_is_missing() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
@@ -953,6 +1168,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
child_thread_id.to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(crate) struct AgentMetadata {
|
||||
pub(crate) agent_path: Option<AgentPath>,
|
||||
pub(crate) agent_nickname: Option<String>,
|
||||
pub(crate) agent_role: Option<String>,
|
||||
pub(crate) last_task_message: Option<String>,
|
||||
}
|
||||
|
||||
fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String {
|
||||
@@ -151,6 +152,34 @@ impl AgentRegistry {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn live_agents(&self) -> Vec<AgentMetadata> {
|
||||
self.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.agent_tree
|
||||
.values()
|
||||
.filter(|metadata| {
|
||||
metadata.agent_id.is_some()
|
||||
&& !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) {
|
||||
let mut active_agents = self
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(metadata) = active_agents
|
||||
.agent_tree
|
||||
.values_mut()
|
||||
.find(|metadata| metadata.agent_id == Some(thread_id))
|
||||
{
|
||||
metadata.last_task_message = Some(last_task_message);
|
||||
}
|
||||
}
|
||||
|
||||
fn register_spawned_thread(&self, agent_metadata: AgentMetadata) {
|
||||
let Some(thread_id) = agent_metadata.agent_id else {
|
||||
return;
|
||||
|
||||
@@ -3956,6 +3956,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
ts.pending_input_snapshot()
|
||||
}
|
||||
None => Vec::with_capacity(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -3970,6 +3981,12 @@ impl Session {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn queued_response_items_for_next_turn_snapshot(
|
||||
&self,
|
||||
) -> Vec<ResponseInputItem> {
|
||||
self.idle_pending_input.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
@@ -4550,6 +4567,7 @@ mod handlers {
|
||||
Op::UserTurn {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
model,
|
||||
effort,
|
||||
@@ -4575,7 +4593,7 @@ mod handlers {
|
||||
SessionSettingsUpdate {
|
||||
cwd: Some(cwd),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer: None,
|
||||
approvals_reviewer,
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
windows_sandbox_level: None,
|
||||
collaboration_mode,
|
||||
|
||||
@@ -3,9 +3,11 @@ use super::*;
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::InitialHistory;
|
||||
use crate::protocol::ResumedHistory;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -34,11 +36,17 @@ fn assistant_message(text: &str) -> ResponseItem {
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_message(text: &str) -> ResponseItem {
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::root().join("worker").unwrap(),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
);
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
text: serde_json::to_string(&communication).unwrap(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -455,9 +463,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
turn_id: Some(assistant_turn_id.clone()),
|
||||
..first_context_item.clone()
|
||||
};
|
||||
let assistant_instruction = inter_agent_assistant_message(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let assistant_instruction = inter_agent_assistant_message("continue");
|
||||
let assistant_reply = assistant_message("worker reply");
|
||||
|
||||
let rollout_items = vec![
|
||||
|
||||
@@ -3013,6 +3013,41 @@ fn op_kind_distinguishes_turn_ops() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_turn_updates_approvals_reviewer() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let config = session.get_config().await;
|
||||
|
||||
handlers::user_input_or_turn(
|
||||
&session,
|
||||
"sub-1".to_string(),
|
||||
Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
effort: config.model_reasoning_effort,
|
||||
summary: config.model_reasoning_summary,
|
||||
service_tier: None,
|
||||
final_output_json_schema: None,
|
||||
collaboration_mode: None,
|
||||
personality: config.personality,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let state = session.state.lock().await;
|
||||
assert_eq!(
|
||||
state.session_configuration.approvals_reviewer,
|
||||
crate::config::types::ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
|
||||
struct TraceCaptureTask {
|
||||
|
||||
@@ -5640,8 +5640,15 @@ fn write_fake_bwrap(contents: &str) -> tempfile::TempPath {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
// Bazel can mount the OS temp directory `noexec`, so prefer the current
|
||||
// working directory for fake executables and fall back to the default temp
|
||||
// dir outside that environment.
|
||||
let temp_file = std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|dir| NamedTempFile::new_in(dir).ok())
|
||||
.unwrap_or_else(|| NamedTempFile::new().expect("temp file"));
|
||||
// Linux rejects exec-ing a file that is still open for writing.
|
||||
let path = NamedTempFile::new().expect("temp file").into_temp_path();
|
||||
let path = temp_file.into_temp_path();
|
||||
fs::write(&path, contents).expect("write fake bwrap");
|
||||
let permissions = fs::Permissions::from_mode(0o755);
|
||||
fs::set_permissions(&path, permissions).expect("chmod fake bwrap");
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::truncate::TruncationPolicy;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_git::GhostCommit;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
@@ -17,6 +18,7 @@ use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use image::ImageBuffer;
|
||||
use image::ImageFormat;
|
||||
use image::Rgba;
|
||||
@@ -39,11 +41,17 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::root().join("worker").unwrap(),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
);
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
text: serde_json::to_string(&communication).unwrap(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -239,9 +247,7 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item
|
||||
|
||||
#[test]
|
||||
fn inter_agent_assistant_messages_are_turn_boundaries() {
|
||||
let item = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let item = inter_agent_assistant_msg("continue");
|
||||
|
||||
assert!(is_user_turn_boundary(&item));
|
||||
}
|
||||
@@ -250,9 +256,7 @@ fn inter_agent_assistant_messages_are_turn_boundaries() {
|
||||
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
|
||||
let first_turn = user_input_text_msg("first");
|
||||
let first_reply = assistant_msg("done");
|
||||
let inter_agent_turn = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let inter_agent_turn = inter_agent_assistant_msg("continue");
|
||||
let inter_agent_reply = assistant_msg("worker reply");
|
||||
let mut history = create_history_with_items(vec![
|
||||
first_turn.clone(),
|
||||
@@ -266,6 +270,15 @@ fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_t
|
||||
assert_eq!(history.raw_items(), &vec![first_turn, first_reply]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inter_agent_assistant_messages_are_not_turn_boundaries() {
|
||||
let item = assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
|
||||
assert!(!is_user_turn_boundary(&item));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_token_usage_includes_all_items_after_last_model_generated_item() {
|
||||
let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]);
|
||||
|
||||
@@ -460,6 +460,21 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Unknown(plan))
|
||||
if plan.eq_ignore_ascii_case("self_serve_business_usage_based") =>
|
||||
{
|
||||
match self
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.credits.as_ref())
|
||||
.map(|credits| credits.has_credits)
|
||||
{
|
||||
Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue."
|
||||
.to_string(),
|
||||
Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
Some(PlanType::Unknown(_)) | None => format!(
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
|
||||
@@ -514,6 +514,7 @@ async fn run_review_on_session(
|
||||
items: params.prompt_items.clone(),
|
||||
cwd: params.parent_turn.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: params.model.clone(),
|
||||
effort: params.reasoning_effort,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_hooks::PreToolUseOutcome;
|
||||
use codex_hooks::PreToolUseRequest;
|
||||
use codex_hooks::SessionStartOutcome;
|
||||
use codex_hooks::UserPromptSubmitOutcome;
|
||||
use codex_hooks::UserPromptSubmitRequest;
|
||||
@@ -109,6 +111,36 @@ pub(crate) async fn run_pending_session_start_hooks(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_tool_use_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
tool_use_id: String,
|
||||
command: String,
|
||||
) -> Option<String> {
|
||||
let request = PreToolUseRequest {
|
||||
session_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
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),
|
||||
tool_name: "Bash".to_string(),
|
||||
tool_use_id,
|
||||
command,
|
||||
};
|
||||
let preview_runs = sess.hooks().preview_pre_tool_use(&request);
|
||||
emit_hook_started_events(sess, turn_context, preview_runs).await;
|
||||
|
||||
let PreToolUseOutcome {
|
||||
hook_events,
|
||||
should_block,
|
||||
block_reason,
|
||||
} = sess.hooks().run_pre_tool_use(request).await;
|
||||
emit_hook_completed_events(sess, turn_context, hook_events).await;
|
||||
|
||||
if should_block { block_reason } else { None }
|
||||
}
|
||||
|
||||
pub(crate) async fn run_user_prompt_submit_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
|
||||
@@ -5,6 +5,8 @@ use crate::spawn::spawn_child_async;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_sandboxing::landlock::allow_network_for_proxy;
|
||||
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -59,112 +61,3 @@ where
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
|
||||
// When managed network requirements are active, request proxy-only
|
||||
// networking from the Linux sandbox helper. Without managed requirements,
|
||||
// preserve existing behavior.
|
||||
enforce_managed_network
|
||||
}
|
||||
|
||||
/// Converts the sandbox policies into the CLI invocation for
|
||||
/// `codex-linux-sandbox`.
|
||||
///
|
||||
/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after
|
||||
/// parsing these arguments. Policy JSON flags are emitted before helper feature
|
||||
/// flags so the argv order matches the helper's CLI shape. See
|
||||
/// `docs/linux_sandbox.md` for the Linux semantics.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_linux_sandbox_command_args_for_policies(
|
||||
command: Vec<String>,
|
||||
command_cwd: &Path,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_legacy_landlock: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> Vec<String> {
|
||||
let sandbox_policy_json = serde_json::to_string(sandbox_policy)
|
||||
.unwrap_or_else(|err| panic!("failed to serialize sandbox policy: {err}"));
|
||||
let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy)
|
||||
.unwrap_or_else(|err| panic!("failed to serialize filesystem sandbox policy: {err}"));
|
||||
let network_policy_json = serde_json::to_string(&network_sandbox_policy)
|
||||
.unwrap_or_else(|err| panic!("failed to serialize network sandbox policy: {err}"));
|
||||
let sandbox_policy_cwd = sandbox_policy_cwd
|
||||
.to_str()
|
||||
.unwrap_or_else(|| panic!("cwd must be valid UTF-8"))
|
||||
.to_string();
|
||||
let command_cwd = command_cwd
|
||||
.to_str()
|
||||
.unwrap_or_else(|| panic!("command cwd must be valid UTF-8"))
|
||||
.to_string();
|
||||
|
||||
let mut linux_cmd: Vec<String> = vec![
|
||||
"--sandbox-policy-cwd".to_string(),
|
||||
sandbox_policy_cwd,
|
||||
"--command-cwd".to_string(),
|
||||
command_cwd,
|
||||
"--sandbox-policy".to_string(),
|
||||
sandbox_policy_json,
|
||||
"--file-system-sandbox-policy".to_string(),
|
||||
file_system_policy_json,
|
||||
"--network-sandbox-policy".to_string(),
|
||||
network_policy_json,
|
||||
];
|
||||
if use_legacy_landlock {
|
||||
linux_cmd.push("--use-legacy-landlock".to_string());
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
linux_cmd.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
linux_cmd.push("--".to_string());
|
||||
linux_cmd.extend(command);
|
||||
linux_cmd
|
||||
}
|
||||
|
||||
/// Converts the sandbox cwd and execution options into the CLI invocation for
|
||||
/// `codex-linux-sandbox`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn create_linux_sandbox_command_args(
|
||||
command: Vec<String>,
|
||||
command_cwd: &Path,
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_legacy_landlock: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> Vec<String> {
|
||||
let command_cwd = command_cwd
|
||||
.to_str()
|
||||
.unwrap_or_else(|| panic!("command cwd must be valid UTF-8"))
|
||||
.to_string();
|
||||
let sandbox_policy_cwd = sandbox_policy_cwd
|
||||
.to_str()
|
||||
.unwrap_or_else(|| panic!("cwd must be valid UTF-8"))
|
||||
.to_string();
|
||||
|
||||
let mut linux_cmd: Vec<String> = vec![
|
||||
"--sandbox-policy-cwd".to_string(),
|
||||
sandbox_policy_cwd,
|
||||
"--command-cwd".to_string(),
|
||||
command_cwd,
|
||||
];
|
||||
if use_legacy_landlock {
|
||||
linux_cmd.push("--use-legacy-landlock".to_string());
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
linux_cmd.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
|
||||
// Separator so that command arguments starting with `-` are not parsed as
|
||||
// options of the helper itself.
|
||||
linux_cmd.push("--".to_string());
|
||||
|
||||
// Append the original tool command.
|
||||
linux_cmd.extend(command);
|
||||
|
||||
linux_cmd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "landlock_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -93,10 +93,10 @@ mod event_mapping;
|
||||
mod response_debug_context;
|
||||
pub mod review_format;
|
||||
pub mod review_prompts;
|
||||
mod seatbelt_permissions;
|
||||
mod thread_manager;
|
||||
pub mod web_search;
|
||||
pub mod windows_sandbox_read_grants;
|
||||
pub use thread_manager::ForkSnapshot;
|
||||
pub use thread_manager::NewThread;
|
||||
pub use thread_manager::ThreadManager;
|
||||
#[deprecated(note = "use ThreadManager")]
|
||||
|
||||
@@ -46,8 +46,8 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec<us
|
||||
/// a prefix that excludes the first user message and everything after it).
|
||||
///
|
||||
/// If `n_from_start` is `usize::MAX`, this returns the full rollout (no truncation).
|
||||
/// If fewer than or equal to `n_from_start` user messages exist, this returns an empty
|
||||
/// vector (out of range).
|
||||
/// If fewer than or equal to `n_from_start` user messages exist, this returns the full
|
||||
/// rollout unchanged.
|
||||
pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
|
||||
items: &[RolloutItem],
|
||||
n_from_start: usize,
|
||||
@@ -58,9 +58,9 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
|
||||
|
||||
let user_positions = user_message_positions_in_rollout(items);
|
||||
|
||||
// If fewer than or equal to n user messages exist, treat as empty (out of range).
|
||||
// If fewer than or equal to n user messages exist, keep the full rollout.
|
||||
if user_positions.len() <= n_from_start {
|
||||
return Vec::new();
|
||||
return items.to_vec();
|
||||
}
|
||||
|
||||
// Cut strictly before the nth user message (do not keep the nth itself).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::*;
|
||||
use crate::codex::make_session_and_context;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
@@ -74,7 +73,10 @@ fn truncates_rollout_from_start_before_nth_user_only() {
|
||||
);
|
||||
|
||||
let truncated2 = truncate_rollout_before_nth_user_message_from_start(&rollout, 2);
|
||||
assert_matches!(truncated2.as_slice(), []);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&truncated2).unwrap(),
|
||||
serde_json::to_value(&rollout).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,46 +6,36 @@ sandbox placement and transformation of portable CommandSpec into a
|
||||
ready‑to‑spawn environment.
|
||||
*/
|
||||
|
||||
pub(crate) mod macos_permissions;
|
||||
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::exec::StdoutStream;
|
||||
use crate::exec::execute_exec_request;
|
||||
use crate::landlock::allow_network_for_proxy;
|
||||
use crate::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
pub use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
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::ReadOnlyAccess;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize;
|
||||
use macos_permissions::intersect_macos_seatbelt_profile_extensions;
|
||||
use macos_permissions::merge_macos_seatbelt_profile_extensions;
|
||||
use codex_sandboxing::landlock::allow_network_for_proxy;
|
||||
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
use codex_sandboxing::policy_transforms::EffectiveSandboxPermissions;
|
||||
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
|
||||
use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
|
||||
use codex_sandboxing::policy_transforms::should_require_platform_sandbox;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -118,425 +108,6 @@ pub(crate) enum SandboxTransformError {
|
||||
SeatbeltUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct EffectiveSandboxPermissions {
|
||||
pub(crate) sandbox_policy: SandboxPolicy,
|
||||
pub(crate) macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
impl EffectiveSandboxPermissions {
|
||||
pub(crate) fn new(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
) -> Self {
|
||||
let Some(additional_permissions) = additional_permissions else {
|
||||
return Self {
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(),
|
||||
};
|
||||
};
|
||||
|
||||
Self {
|
||||
sandbox_policy: sandbox_policy_with_additional_permissions(
|
||||
sandbox_policy,
|
||||
additional_permissions,
|
||||
),
|
||||
macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions(
|
||||
macos_seatbelt_profile_extensions,
|
||||
additional_permissions.macos.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_additional_permissions(
|
||||
additional_permissions: PermissionProfile,
|
||||
) -> Result<PermissionProfile, String> {
|
||||
let network = additional_permissions
|
||||
.network
|
||||
.filter(|network| !network.is_empty());
|
||||
let file_system = additional_permissions
|
||||
.file_system
|
||||
.map(|file_system| {
|
||||
let read = file_system
|
||||
.read
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.read"));
|
||||
let write = file_system
|
||||
.write
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.write"));
|
||||
FileSystemPermissions { read, write }
|
||||
})
|
||||
.filter(|file_system| !file_system.is_empty());
|
||||
let macos = additional_permissions.macos;
|
||||
|
||||
Ok(PermissionProfile {
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn merge_permission_profiles(
|
||||
base: Option<&PermissionProfile>,
|
||||
permissions: Option<&PermissionProfile>,
|
||||
) -> Option<PermissionProfile> {
|
||||
let Some(permissions) = permissions else {
|
||||
return base.cloned();
|
||||
};
|
||||
|
||||
match base {
|
||||
Some(base) => {
|
||||
let network = match (base.network.as_ref(), permissions.network.as_ref()) {
|
||||
(
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
_,
|
||||
)
|
||||
| (
|
||||
_,
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
) => Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) {
|
||||
(Some(base), Some(permissions)) => Some(FileSystemPermissions {
|
||||
read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()),
|
||||
write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()),
|
||||
})
|
||||
.filter(|file_system| !file_system.is_empty()),
|
||||
(Some(base), None) => Some(base.clone()),
|
||||
(None, Some(permissions)) => Some(permissions.clone()),
|
||||
(None, None) => None,
|
||||
};
|
||||
let macos = merge_macos_seatbelt_profile_extensions(
|
||||
base.macos.as_ref(),
|
||||
permissions.macos.as_ref(),
|
||||
);
|
||||
|
||||
Some(PermissionProfile {
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
})
|
||||
.filter(|permissions| !permissions.is_empty())
|
||||
}
|
||||
None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intersect_permission_profiles(
|
||||
requested: PermissionProfile,
|
||||
granted: PermissionProfile,
|
||||
) -> PermissionProfile {
|
||||
let file_system = requested
|
||||
.file_system
|
||||
.map(|requested_file_system| {
|
||||
let granted_file_system = granted.file_system.unwrap_or_default();
|
||||
let read = requested_file_system
|
||||
.read
|
||||
.map(|requested_read| {
|
||||
let granted_read = granted_file_system.read.unwrap_or_default();
|
||||
requested_read
|
||||
.into_iter()
|
||||
.filter(|path| granted_read.contains(path))
|
||||
.collect()
|
||||
})
|
||||
.filter(|paths: &Vec<_>| !paths.is_empty());
|
||||
let write = requested_file_system
|
||||
.write
|
||||
.map(|requested_write| {
|
||||
let granted_write = granted_file_system.write.unwrap_or_default();
|
||||
requested_write
|
||||
.into_iter()
|
||||
.filter(|path| granted_write.contains(path))
|
||||
.collect()
|
||||
})
|
||||
.filter(|paths: &Vec<_>| !paths.is_empty());
|
||||
FileSystemPermissions { read, write }
|
||||
})
|
||||
.filter(|file_system| !file_system.is_empty());
|
||||
let network = match (requested.network, granted.network) {
|
||||
(
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
) => Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos);
|
||||
|
||||
PermissionProfile {
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_permission_paths(
|
||||
paths: Vec<AbsolutePathBuf>,
|
||||
_permission_kind: &str,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut out = Vec::with_capacity(paths.len());
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for path in paths {
|
||||
let canonicalized = canonicalize(path.as_path())
|
||||
.ok()
|
||||
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
|
||||
.unwrap_or(path);
|
||||
if seen.insert(canonicalized.clone()) {
|
||||
out.push(canonicalized);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn merge_permission_paths(
|
||||
base: Option<&Vec<AbsolutePathBuf>>,
|
||||
permissions: Option<&Vec<AbsolutePathBuf>>,
|
||||
) -> Option<Vec<AbsolutePathBuf>> {
|
||||
match (base, permissions) {
|
||||
(Some(base), Some(permissions)) => {
|
||||
let mut merged = Vec::with_capacity(base.len() + permissions.len());
|
||||
let mut seen = HashSet::with_capacity(base.len() + permissions.len());
|
||||
|
||||
for path in base.iter().chain(permissions.iter()) {
|
||||
if seen.insert(path.clone()) {
|
||||
merged.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Some(merged).filter(|paths| !paths.is_empty())
|
||||
}
|
||||
(Some(base), None) => Some(base.clone()),
|
||||
(None, Some(permissions)) => Some(permissions.clone()),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
|
||||
let mut out = Vec::with_capacity(paths.len());
|
||||
let mut seen = HashSet::new();
|
||||
for path in paths {
|
||||
if seen.insert(path.to_path_buf()) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn additional_permission_roots(
|
||||
additional_permissions: &PermissionProfile,
|
||||
) -> (Vec<AbsolutePathBuf>, Vec<AbsolutePathBuf>) {
|
||||
(
|
||||
dedup_absolute_paths(
|
||||
additional_permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|file_system| file_system.read.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
dedup_absolute_paths(
|
||||
additional_permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|file_system| file_system.write.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_file_system_policy_with_additional_permissions(
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
extra_reads: Vec<AbsolutePathBuf>,
|
||||
extra_writes: Vec<AbsolutePathBuf>,
|
||||
) -> FileSystemSandboxPolicy {
|
||||
match file_system_policy.kind {
|
||||
FileSystemSandboxKind::Restricted => {
|
||||
let mut merged_policy = file_system_policy.clone();
|
||||
for path in extra_reads {
|
||||
let entry = FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path },
|
||||
access: FileSystemAccessMode::Read,
|
||||
};
|
||||
if !merged_policy.entries.contains(&entry) {
|
||||
merged_policy.entries.push(entry);
|
||||
}
|
||||
}
|
||||
for path in extra_writes {
|
||||
let entry = FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path },
|
||||
access: FileSystemAccessMode::Write,
|
||||
};
|
||||
if !merged_policy.entries.contains(&entry) {
|
||||
merged_policy.entries.push(entry);
|
||||
}
|
||||
}
|
||||
merged_policy
|
||||
}
|
||||
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
|
||||
file_system_policy.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn effective_file_system_sandbox_policy(
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
) -> FileSystemSandboxPolicy {
|
||||
let Some(additional_permissions) = additional_permissions else {
|
||||
return file_system_policy.clone();
|
||||
};
|
||||
|
||||
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
|
||||
if extra_reads.is_empty() && extra_writes.is_empty() {
|
||||
file_system_policy.clone()
|
||||
} else {
|
||||
merge_file_system_policy_with_additional_permissions(
|
||||
file_system_policy,
|
||||
extra_reads,
|
||||
extra_writes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_read_only_access_with_additional_reads(
|
||||
read_only_access: &ReadOnlyAccess,
|
||||
extra_reads: Vec<AbsolutePathBuf>,
|
||||
) -> ReadOnlyAccess {
|
||||
match read_only_access {
|
||||
ReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess,
|
||||
ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults,
|
||||
readable_roots,
|
||||
} => {
|
||||
let mut merged = readable_roots.clone();
|
||||
merged.extend(extra_reads);
|
||||
ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: *include_platform_defaults,
|
||||
readable_roots: dedup_absolute_paths(merged),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_network_access(
|
||||
base_network_access: bool,
|
||||
additional_permissions: &PermissionProfile,
|
||||
) -> bool {
|
||||
base_network_access
|
||||
|| additional_permissions
|
||||
.network
|
||||
.as_ref()
|
||||
.and_then(|network| network.enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn sandbox_policy_with_additional_permissions(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
additional_permissions: &PermissionProfile,
|
||||
) -> SandboxPolicy {
|
||||
if additional_permissions.is_empty() {
|
||||
return sandbox_policy.clone();
|
||||
}
|
||||
|
||||
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
|
||||
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess,
|
||||
SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox {
|
||||
network_access: if merge_network_access(
|
||||
network_access.is_enabled(),
|
||||
additional_permissions,
|
||||
) {
|
||||
NetworkAccess::Enabled
|
||||
} else {
|
||||
NetworkAccess::Restricted
|
||||
},
|
||||
},
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
read_only_access,
|
||||
network_access,
|
||||
exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp,
|
||||
} => {
|
||||
let mut merged_writes = writable_roots.clone();
|
||||
merged_writes.extend(extra_writes);
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: dedup_absolute_paths(merged_writes),
|
||||
read_only_access: merge_read_only_access_with_additional_reads(
|
||||
read_only_access,
|
||||
extra_reads,
|
||||
),
|
||||
network_access: merge_network_access(*network_access, additional_permissions),
|
||||
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp: *exclude_slash_tmp,
|
||||
}
|
||||
}
|
||||
SandboxPolicy::ReadOnly {
|
||||
access,
|
||||
network_access,
|
||||
} => {
|
||||
if extra_writes.is_empty() {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: merge_read_only_access_with_additional_reads(access, extra_reads),
|
||||
network_access: merge_network_access(*network_access, additional_permissions),
|
||||
}
|
||||
} else {
|
||||
// todo(dylan) - for now, this grants more access than the request. We should restrict this,
|
||||
// but we should add a new SandboxPolicy variant to handle this. While the feature is still
|
||||
// UnderDevelopment, it's a useful approximation of the desired behavior.
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: dedup_absolute_paths(extra_writes),
|
||||
read_only_access: merge_read_only_access_with_additional_reads(
|
||||
access,
|
||||
extra_reads,
|
||||
),
|
||||
network_access: merge_network_access(*network_access, additional_permissions),
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_require_platform_sandbox(
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
network_policy: NetworkSandboxPolicy,
|
||||
has_managed_network_requirements: bool,
|
||||
) -> bool {
|
||||
if has_managed_network_requirements {
|
||||
return true;
|
||||
}
|
||||
|
||||
if !network_policy.is_enabled() {
|
||||
return !matches!(
|
||||
file_system_policy.kind,
|
||||
FileSystemSandboxKind::ExternalSandbox
|
||||
);
|
||||
}
|
||||
|
||||
match file_system_policy.kind {
|
||||
FileSystemSandboxKind::Restricted => !file_system_policy.has_full_disk_write_access(),
|
||||
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SandboxManager;
|
||||
|
||||
@@ -611,30 +182,12 @@ impl SandboxManager {
|
||||
macos_seatbelt_profile_extensions,
|
||||
additional_permissions.as_ref(),
|
||||
);
|
||||
let (effective_file_system_policy, effective_network_policy) =
|
||||
if let Some(additional_permissions) = additional_permissions {
|
||||
let (extra_reads, extra_writes) =
|
||||
additional_permission_roots(&additional_permissions);
|
||||
let file_system_sandbox_policy =
|
||||
if extra_reads.is_empty() && extra_writes.is_empty() {
|
||||
file_system_policy.clone()
|
||||
} else {
|
||||
merge_file_system_policy_with_additional_permissions(
|
||||
file_system_policy,
|
||||
extra_reads,
|
||||
extra_writes,
|
||||
)
|
||||
};
|
||||
let network_sandbox_policy =
|
||||
if merge_network_access(network_policy.is_enabled(), &additional_permissions) {
|
||||
NetworkSandboxPolicy::Enabled
|
||||
} else {
|
||||
NetworkSandboxPolicy::Restricted
|
||||
};
|
||||
(file_system_sandbox_policy, network_sandbox_policy)
|
||||
} else {
|
||||
(file_system_policy.clone(), network_policy)
|
||||
};
|
||||
let effective_file_system_policy = effective_file_system_sandbox_policy(
|
||||
file_system_policy,
|
||||
additional_permissions.as_ref(),
|
||||
);
|
||||
let effective_network_policy =
|
||||
effective_network_sandbox_policy(network_policy, additional_permissions.as_ref());
|
||||
let mut env = spec.env;
|
||||
if !effective_network_policy.is_enabled() {
|
||||
env.insert(
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::EffectiveSandboxPermissions;
|
||||
use super::SandboxManager;
|
||||
use super::effective_file_system_sandbox_policy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::intersect_permission_profiles;
|
||||
use super::merge_file_system_policy_with_additional_permissions;
|
||||
use super::normalize_additional_permissions;
|
||||
use super::sandbox_policy_with_additional_permissions;
|
||||
use super::should_require_platform_sandbox;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
@@ -15,14 +6,6 @@ use crate::protocol::SandboxPolicy;
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsAutomationPermission;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsContactsPermission;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
@@ -35,15 +18,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> {
|
||||
std::os::unix::fs::symlink(original, link)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
|
||||
let manager = SandboxManager::new();
|
||||
@@ -90,62 +66,6 @@ fn restricted_file_system_uses_platform_sandbox_without_managed_network() {
|
||||
assert_eq!(sandbox, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() {
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_write_policy_with_carveouts_still_uses_platform_sandbox() {
|
||||
let blocked = AbsolutePathBuf::resolve_path_against_base(
|
||||
"blocked",
|
||||
std::env::current_dir().expect("current dir"),
|
||||
)
|
||||
.expect("blocked path");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: blocked },
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() {
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() {
|
||||
let manager = SandboxManager::new();
|
||||
@@ -191,317 +111,6 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_additional_permissions_preserves_network() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let path = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![path.clone()]),
|
||||
write: Some(vec![path.clone()]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions.network,
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
permissions.file_system,
|
||||
Some(FileSystemPermissions {
|
||||
read: Some(vec![path.clone()]),
|
||||
write: Some(vec![path]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn normalize_additional_permissions_canonicalizes_symlinked_write_paths() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let real_root = temp_dir.path().join("real");
|
||||
let link_root = temp_dir.path().join("link");
|
||||
let write_dir = real_root.join("write");
|
||||
std::fs::create_dir_all(&write_dir).expect("create write dir");
|
||||
symlink_dir(&real_root, &link_root).expect("create symlinked root");
|
||||
|
||||
let link_write_dir =
|
||||
AbsolutePathBuf::from_absolute_path(link_root.join("write")).expect("link write dir");
|
||||
let expected_write_dir = AbsolutePathBuf::from_absolute_path(
|
||||
write_dir.canonicalize().expect("canonicalize write dir"),
|
||||
)
|
||||
.expect("absolute canonical write dir");
|
||||
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![link_write_dir]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions.file_system,
|
||||
Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![expected_write_dir]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_additional_permissions_drops_empty_nested_profiles() {
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
network: Some(NetworkPermissions { enabled: None }),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: None,
|
||||
}),
|
||||
macos: None,
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(permissions, PermissionProfile::default());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn normalize_additional_permissions_preserves_default_macos_preferences_permission() {
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions,
|
||||
PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn intersect_permission_profiles_preserves_default_macos_grants() {
|
||||
let requested = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(Vec::from(["/tmp/requested"
|
||||
.try_into()
|
||||
.expect("absolute path")])),
|
||||
write: None,
|
||||
}),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_launch_services: false,
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
macos_reminders: false,
|
||||
macos_contacts: MacOsContactsPermission::None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let granted = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(Vec::new()),
|
||||
write: None,
|
||||
}),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
intersect_permission_profiles(requested, granted),
|
||||
PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn normalize_additional_permissions_preserves_macos_permissions() {
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_launch_services: true,
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
macos_reminders: false,
|
||||
macos_contacts: MacOsContactsPermission::None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions.macos,
|
||||
Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_launch_services: true,
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
macos_reminders: false,
|
||||
macos_contacts: MacOsContactsPermission::None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_additional_permissions_can_enable_network_without_writes() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let path = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let policy = sandbox_policy_with_additional_permissions(
|
||||
&SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![path.clone()],
|
||||
},
|
||||
network_access: false,
|
||||
},
|
||||
&PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![path.clone()]),
|
||||
write: Some(Vec::new()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
policy,
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![path],
|
||||
},
|
||||
network_access: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn effective_permissions_merge_macos_extensions_with_additional_permissions() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let path = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let effective_permissions = EffectiveSandboxPermissions::new(
|
||||
&SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![path.clone()],
|
||||
},
|
||||
network_access: false,
|
||||
},
|
||||
Some(&MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Calendar".to_string(),
|
||||
]),
|
||||
macos_launch_services: false,
|
||||
macos_accessibility: false,
|
||||
macos_calendar: false,
|
||||
macos_reminders: false,
|
||||
macos_contacts: MacOsContactsPermission::None,
|
||||
}),
|
||||
Some(&PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![path]),
|
||||
write: Some(Vec::new()),
|
||||
}),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_launch_services: true,
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
macos_reminders: false,
|
||||
macos_contacts: MacOsContactsPermission::None,
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
effective_permissions.macos_seatbelt_profile_extensions,
|
||||
Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Calendar".to_string(),
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_launch_services: true,
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
macos_reminders: false,
|
||||
macos_contacts: MacOsContactsPermission::None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_sandbox_additional_permissions_can_enable_network() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let path = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let policy = sandbox_policy_with_additional_permissions(
|
||||
&SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
},
|
||||
&PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![path]),
|
||||
write: Some(Vec::new()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
policy,
|
||||
SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_additional_permissions_enable_network_for_external_sandbox() {
|
||||
let manager = SandboxManager::new();
|
||||
@@ -649,123 +258,3 @@ fn transform_additional_permissions_preserves_denied_entries() {
|
||||
NetworkSandboxPolicy::Restricted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let allowed_path = cwd.join("allowed").expect("allowed path");
|
||||
let denied_path = cwd.join("denied").expect("denied path");
|
||||
let merged_policy = merge_file_system_policy_with_additional_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: denied_path.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]),
|
||||
vec![allowed_path.clone()],
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged_policy.entries.contains(&FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: denied_path },
|
||||
access: FileSystemAccessMode::None,
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
merged_policy.entries.contains(&FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: allowed_path },
|
||||
access: FileSystemAccessMode::Read,
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_file_system_sandbox_policy_returns_base_policy_without_additional_permissions() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let denied_path = cwd.join("denied").expect("denied path");
|
||||
let base_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: denied_path },
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
|
||||
let effective_policy = effective_file_system_sandbox_policy(&base_policy, None);
|
||||
|
||||
assert_eq!(effective_policy, base_policy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_file_system_sandbox_policy_merges_additional_write_roots() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let allowed_path = cwd.join("allowed").expect("allowed path");
|
||||
let denied_path = cwd.join("denied").expect("denied path");
|
||||
let base_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: denied_path.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
let additional_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![allowed_path.clone()]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let effective_policy =
|
||||
effective_file_system_sandbox_policy(&base_policy, Some(&additional_permissions));
|
||||
|
||||
assert_eq!(
|
||||
effective_policy.entries.contains(&FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: denied_path },
|
||||
access: FileSystemAccessMode::None,
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
effective_policy.entries.contains(&FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: allowed_path },
|
||||
access: FileSystemAccessMode::Write,
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
+11
-556
@@ -1,40 +1,19 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::PROXY_URL_ENV_KEYS;
|
||||
use codex_network_proxy::has_proxy_url_env_vars;
|
||||
use codex_network_proxy::proxy_url_env_value;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::CStr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
use crate::seatbelt_permissions::build_seatbelt_extensions;
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::SpawnChildRequest;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
|
||||
const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
|
||||
const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
|
||||
const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str =
|
||||
include_str!("restricted_read_only_platform_defaults.sbpl");
|
||||
|
||||
/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
|
||||
/// to defend against an attacker trying to inject a malicious version on the
|
||||
/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker
|
||||
/// already has root access.
|
||||
pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
|
||||
pub async fn spawn_command_under_seatbelt(
|
||||
command: Vec<String>,
|
||||
@@ -45,12 +24,14 @@ pub async fn spawn_command_under_seatbelt(
|
||||
network: Option<&NetworkProxy>,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args = create_seatbelt_command_args(
|
||||
let args = create_seatbelt_command_args_for_policies_with_extensions(
|
||||
command,
|
||||
sandbox_policy,
|
||||
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
|
||||
NetworkSandboxPolicy::from(sandbox_policy),
|
||||
sandbox_policy_cwd,
|
||||
/*enforce_managed_network*/ false,
|
||||
network,
|
||||
/*extensions*/ None,
|
||||
);
|
||||
let arg0 = None;
|
||||
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
@@ -66,529 +47,3 @@ pub async fn spawn_command_under_seatbelt(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn is_loopback_host(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
fn proxy_scheme_default_port(scheme: &str) -> u16 {
|
||||
match scheme {
|
||||
"https" => 443,
|
||||
"socks5" | "socks5h" | "socks4" | "socks4a" => 1080,
|
||||
_ => 80,
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_loopback_ports_from_env(env: &HashMap<String, String>) -> Vec<u16> {
|
||||
let mut ports = BTreeSet::new();
|
||||
for key in PROXY_URL_ENV_KEYS {
|
||||
let Some(proxy_url) = proxy_url_env_value(env, key) else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = proxy_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate = if trimmed.contains("://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
let Ok(parsed) = Url::parse(&candidate) else {
|
||||
continue;
|
||||
};
|
||||
let Some(host) = parsed.host_str() else {
|
||||
continue;
|
||||
};
|
||||
if !is_loopback_host(host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let scheme = parsed.scheme().to_ascii_lowercase();
|
||||
let port = parsed
|
||||
.port()
|
||||
.unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str()));
|
||||
ports.insert(port);
|
||||
}
|
||||
ports.into_iter().collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ProxyPolicyInputs {
|
||||
ports: Vec<u16>,
|
||||
has_proxy_config: bool,
|
||||
allow_local_binding: bool,
|
||||
unix_domain_socket_policy: UnixDomainSocketPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
// Keep allow-all and allowlist modes disjoint so we don't carry ignored state.
|
||||
enum UnixDomainSocketPolicy {
|
||||
AllowAll,
|
||||
Restricted { allowed: Vec<AbsolutePathBuf> },
|
||||
}
|
||||
|
||||
impl Default for UnixDomainSocketPolicy {
|
||||
fn default() -> Self {
|
||||
Self::Restricted { allowed: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UnixSocketPathParam {
|
||||
index: usize,
|
||||
path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs {
|
||||
if let Some(network) = network {
|
||||
let mut env = HashMap::new();
|
||||
network.apply_to_env(&mut env);
|
||||
let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() {
|
||||
UnixDomainSocketPolicy::AllowAll
|
||||
} else {
|
||||
let allowed = network
|
||||
.allow_unix_sockets()
|
||||
.iter()
|
||||
.filter_map(
|
||||
|socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) {
|
||||
Some(path) => Some((path.to_string_lossy().to_string(), path)),
|
||||
None => {
|
||||
warn!(
|
||||
"ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
.into_values()
|
||||
.collect();
|
||||
UnixDomainSocketPolicy::Restricted { allowed }
|
||||
};
|
||||
return ProxyPolicyInputs {
|
||||
ports: proxy_loopback_ports_from_env(&env),
|
||||
has_proxy_config: has_proxy_url_env_vars(&env),
|
||||
allow_local_binding: network.allow_local_binding(),
|
||||
unix_domain_socket_policy,
|
||||
};
|
||||
}
|
||||
|
||||
ProxyPolicyInputs::default()
|
||||
}
|
||||
|
||||
fn normalize_path_for_sandbox(path: &Path) -> Option<AbsolutePathBuf> {
|
||||
// `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current
|
||||
// working directory, so keep the explicit check to avoid silently accepting relative entries.
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?;
|
||||
let normalized_path = absolute_path
|
||||
.as_path()
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok());
|
||||
normalized_path.or(Some(absolute_path))
|
||||
}
|
||||
|
||||
fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec<UnixSocketPathParam> {
|
||||
let mut deduped_paths: BTreeMap<String, AbsolutePathBuf> = BTreeMap::new();
|
||||
let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else {
|
||||
return vec![];
|
||||
};
|
||||
for path in allowed {
|
||||
deduped_paths
|
||||
.entry(path.to_string_lossy().to_string())
|
||||
.or_insert_with(|| path.clone());
|
||||
}
|
||||
|
||||
deduped_paths
|
||||
.into_values()
|
||||
.enumerate()
|
||||
.map(|(index, path)| UnixSocketPathParam { index, path })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unix_socket_path_param_key(index: usize) -> String {
|
||||
format!("UNIX_SOCKET_PATH_{index}")
|
||||
}
|
||||
|
||||
fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> {
|
||||
unix_socket_path_params(proxy)
|
||||
.into_iter()
|
||||
.map(|param| {
|
||||
(
|
||||
unix_socket_path_param_key(param.index),
|
||||
param.path.into_path_buf(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns zero or more complete Seatbelt policy lines for unix socket rules.
|
||||
/// When non-empty, the returned string is newline-terminated so callers can
|
||||
/// append it directly to larger policy blocks.
|
||||
fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String {
|
||||
let socket_params = unix_socket_path_params(proxy);
|
||||
let has_unix_socket_access = matches!(
|
||||
proxy.unix_domain_socket_policy,
|
||||
UnixDomainSocketPolicy::AllowAll
|
||||
) || !socket_params.is_empty();
|
||||
if !has_unix_socket_access {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut policy = String::new();
|
||||
policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n");
|
||||
if matches!(
|
||||
proxy.unix_domain_socket_policy,
|
||||
UnixDomainSocketPolicy::AllowAll
|
||||
) {
|
||||
// Keep AllowAll genuinely broad here; path qualifiers look narrower
|
||||
// without a clear macOS behavioral benefit.
|
||||
policy.push_str("(allow network-bind (local unix-socket))\n");
|
||||
policy.push_str("(allow network-outbound (remote unix-socket))\n");
|
||||
return policy;
|
||||
}
|
||||
|
||||
for param in socket_params {
|
||||
let key = unix_socket_path_param_key(param.index);
|
||||
// Use subpath so allowlists cover sockets created beneath approved directories.
|
||||
policy.push_str(&format!(
|
||||
"(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n"
|
||||
));
|
||||
policy.push_str(&format!(
|
||||
"(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n"
|
||||
));
|
||||
}
|
||||
policy
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
fn dynamic_network_policy(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
enforce_managed_network: bool,
|
||||
proxy: &ProxyPolicyInputs,
|
||||
) -> String {
|
||||
dynamic_network_policy_for_network(
|
||||
NetworkSandboxPolicy::from(sandbox_policy),
|
||||
enforce_managed_network,
|
||||
proxy,
|
||||
)
|
||||
}
|
||||
|
||||
fn dynamic_network_policy_for_network(
|
||||
network_policy: NetworkSandboxPolicy,
|
||||
enforce_managed_network: bool,
|
||||
proxy: &ProxyPolicyInputs,
|
||||
) -> String {
|
||||
let should_use_restricted_network_policy =
|
||||
!proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network;
|
||||
if should_use_restricted_network_policy {
|
||||
let mut policy = String::new();
|
||||
if proxy.allow_local_binding {
|
||||
policy.push_str("; allow loopback local binding and loopback traffic\n");
|
||||
policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n");
|
||||
policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
|
||||
policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n");
|
||||
}
|
||||
for port in &proxy.ports {
|
||||
policy.push_str(&format!(
|
||||
"(allow network-outbound (remote ip \"localhost:{port}\"))\n"
|
||||
));
|
||||
}
|
||||
let unix_socket_policy = unix_socket_policy(proxy);
|
||||
if !unix_socket_policy.is_empty() {
|
||||
policy.push_str("; allow unix domain sockets for local IPC\n");
|
||||
policy.push_str(&unix_socket_policy);
|
||||
}
|
||||
return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}");
|
||||
}
|
||||
|
||||
if proxy.has_proxy_config {
|
||||
// Proxy configuration is present but we could not infer any valid loopback endpoints.
|
||||
// Fail closed to avoid silently widening network access in proxy-enforced sessions.
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if enforce_managed_network {
|
||||
// Managed network requirements are active but no usable proxy endpoints
|
||||
// are available. Fail closed for network access.
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if network_policy.is_enabled() {
|
||||
// No proxy env is configured: retain the existing full-network behavior.
|
||||
format!(
|
||||
"(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}"
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn create_seatbelt_command_args(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
enforce_managed_network: bool,
|
||||
network: Option<&NetworkProxy>,
|
||||
) -> Vec<String> {
|
||||
create_seatbelt_command_args_with_extensions(
|
||||
command,
|
||||
sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
enforce_managed_network,
|
||||
network,
|
||||
/*extensions*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
fn root_absolute_path() -> AbsolutePathBuf {
|
||||
match AbsolutePathBuf::from_absolute_path(Path::new("/")) {
|
||||
Ok(path) => path,
|
||||
Err(err) => panic!("root path must be absolute: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SeatbeltAccessRoot {
|
||||
root: AbsolutePathBuf,
|
||||
excluded_subpaths: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
fn build_seatbelt_access_policy(
|
||||
action: &str,
|
||||
param_prefix: &str,
|
||||
roots: Vec<SeatbeltAccessRoot>,
|
||||
) -> (String, Vec<(String, PathBuf)>) {
|
||||
let mut policy_components = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
|
||||
for (index, access_root) in roots.into_iter().enumerate() {
|
||||
let root =
|
||||
normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root);
|
||||
let root_param = format!("{param_prefix}_{index}");
|
||||
params.push((root_param.clone(), root.into_path_buf()));
|
||||
|
||||
if access_root.excluded_subpaths.is_empty() {
|
||||
policy_components.push(format!("(subpath (param \"{root_param}\"))"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")];
|
||||
for (excluded_index, excluded_subpath) in
|
||||
access_root.excluded_subpaths.into_iter().enumerate()
|
||||
{
|
||||
let excluded_subpath =
|
||||
normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath);
|
||||
let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}");
|
||||
params.push((excluded_param.clone(), excluded_subpath.into_path_buf()));
|
||||
require_parts.push(format!(
|
||||
"(require-not (subpath (param \"{excluded_param}\")))"
|
||||
));
|
||||
}
|
||||
policy_components.push(format!("(require-all {} )", require_parts.join(" ")));
|
||||
}
|
||||
|
||||
if policy_components.is_empty() {
|
||||
(String::new(), Vec::new())
|
||||
} else {
|
||||
(
|
||||
format!("(allow {action}\n{}\n)", policy_components.join(" ")),
|
||||
params,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn create_seatbelt_command_args_with_extensions(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
enforce_managed_network: bool,
|
||||
network: Option<&NetworkProxy>,
|
||||
extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> Vec<String> {
|
||||
create_seatbelt_command_args_for_policies_with_extensions(
|
||||
command,
|
||||
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
|
||||
NetworkSandboxPolicy::from(sandbox_policy),
|
||||
sandbox_policy_cwd,
|
||||
enforce_managed_network,
|
||||
network,
|
||||
extensions,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_seatbelt_command_args_for_policies_with_extensions(
|
||||
command: Vec<String>,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
enforce_managed_network: bool,
|
||||
network: Option<&NetworkProxy>,
|
||||
extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> Vec<String> {
|
||||
let unreadable_roots =
|
||||
file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let (file_write_policy, file_write_dir_params) =
|
||||
if file_system_sandbox_policy.has_full_disk_write_access() {
|
||||
if unreadable_roots.is_empty() {
|
||||
// Allegedly, this is more permissive than `(allow file-write*)`.
|
||||
(
|
||||
r#"(allow file-write* (regex #"^/"))"#.to_string(),
|
||||
Vec::new(),
|
||||
)
|
||||
} else {
|
||||
build_seatbelt_access_policy(
|
||||
"file-write*",
|
||||
"WRITABLE_ROOT",
|
||||
vec![SeatbeltAccessRoot {
|
||||
root: root_absolute_path(),
|
||||
excluded_subpaths: unreadable_roots.clone(),
|
||||
}],
|
||||
)
|
||||
}
|
||||
} else {
|
||||
build_seatbelt_access_policy(
|
||||
"file-write*",
|
||||
"WRITABLE_ROOT",
|
||||
file_system_sandbox_policy
|
||||
.get_writable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.into_iter()
|
||||
.map(|root| SeatbeltAccessRoot {
|
||||
root: root.root,
|
||||
excluded_subpaths: root.read_only_subpaths,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
let (file_read_policy, file_read_dir_params) =
|
||||
if file_system_sandbox_policy.has_full_disk_read_access() {
|
||||
if unreadable_roots.is_empty() {
|
||||
(
|
||||
"; allow read-only file operations\n(allow file-read*)".to_string(),
|
||||
Vec::new(),
|
||||
)
|
||||
} else {
|
||||
let (policy, params) = build_seatbelt_access_policy(
|
||||
"file-read*",
|
||||
"READABLE_ROOT",
|
||||
vec![SeatbeltAccessRoot {
|
||||
root: root_absolute_path(),
|
||||
excluded_subpaths: unreadable_roots,
|
||||
}],
|
||||
);
|
||||
(
|
||||
format!("; allow read-only file operations\n{policy}"),
|
||||
params,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let (policy, params) = build_seatbelt_access_policy(
|
||||
"file-read*",
|
||||
"READABLE_ROOT",
|
||||
file_system_sandbox_policy
|
||||
.get_readable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.into_iter()
|
||||
.map(|root| SeatbeltAccessRoot {
|
||||
excluded_subpaths: unreadable_roots
|
||||
.iter()
|
||||
.filter(|path| path.as_path().starts_with(root.as_path()))
|
||||
.cloned()
|
||||
.collect(),
|
||||
root,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if policy.is_empty() {
|
||||
(String::new(), params)
|
||||
} else {
|
||||
(
|
||||
format!("; allow read-only file operations\n{policy}"),
|
||||
params,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let proxy = proxy_policy_inputs(network);
|
||||
let network_policy =
|
||||
dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy);
|
||||
let seatbelt_extensions = extensions.map_or_else(
|
||||
|| {
|
||||
// Backward-compatibility default when no extension profile is provided.
|
||||
build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default())
|
||||
},
|
||||
build_seatbelt_extensions,
|
||||
);
|
||||
|
||||
let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults();
|
||||
let mut policy_sections = vec![
|
||||
MACOS_SEATBELT_BASE_POLICY.to_string(),
|
||||
file_read_policy,
|
||||
file_write_policy,
|
||||
network_policy,
|
||||
];
|
||||
if include_platform_defaults {
|
||||
policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string());
|
||||
}
|
||||
if !seatbelt_extensions.policy.is_empty() {
|
||||
policy_sections.push(seatbelt_extensions.policy.clone());
|
||||
}
|
||||
|
||||
let full_policy = policy_sections.join("\n");
|
||||
|
||||
let dir_params = [
|
||||
file_read_dir_params,
|
||||
file_write_dir_params,
|
||||
macos_dir_params(),
|
||||
unix_socket_dir_params(&proxy),
|
||||
seatbelt_extensions.dir_params,
|
||||
]
|
||||
.concat();
|
||||
|
||||
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
|
||||
let definition_args = dir_params
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy()));
|
||||
seatbelt_args.extend(definition_args);
|
||||
seatbelt_args.push("--".to_string());
|
||||
seatbelt_args.extend(command);
|
||||
seatbelt_args
|
||||
}
|
||||
|
||||
/// Wraps libc::confstr to return a String.
|
||||
fn confstr(name: libc::c_int) -> Option<String> {
|
||||
let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1];
|
||||
let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) };
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
// confstr guarantees NUL-termination when len > 0.
|
||||
let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) };
|
||||
cstr.to_str().ok().map(ToString::to_string)
|
||||
}
|
||||
|
||||
/// Wraps confstr to return a canonicalized PathBuf.
|
||||
fn confstr_path(name: libc::c_int) -> Option<PathBuf> {
|
||||
let s = confstr(name)?;
|
||||
let path = PathBuf::from(s);
|
||||
path.canonicalize().ok().or(Some(path))
|
||||
}
|
||||
|
||||
fn macos_dir_params() -> Vec<(String, PathBuf)> {
|
||||
if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) {
|
||||
return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "seatbelt_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -11,7 +12,6 @@ use crate::context_manager::ContextManager;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Turn-scoped state and active turn metadata scaffolding.
|
||||
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -19,7 +20,6 @@ use tokio::sync::oneshot;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::tasks::SessionTask;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
|
||||
@@ -198,6 +198,10 @@ impl TurnState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
|
||||
self.pending_input.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_input(&self) -> bool {
|
||||
!self.pending_input.is_empty()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use tracing::warn;
|
||||
use crate::AuthManager;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::contextual_user_message::TURN_ABORTED_CLOSE_TAG;
|
||||
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::hook_runtime::PendingInputHookDisposition;
|
||||
use crate::hook_runtime::inspect_pending_input;
|
||||
@@ -60,6 +61,22 @@ pub(crate) use user_shell::execute_user_shell_command;
|
||||
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
|
||||
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
|
||||
|
||||
/// Shared model-visible marker used by both the real interrupt path and
|
||||
/// interrupted fork snapshots.
|
||||
pub(crate) fn interrupted_turn_history_marker() -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!(
|
||||
"{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n{TURN_ABORTED_CLOSE_TAG}"
|
||||
),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_turn_network_proxy_metric(
|
||||
session_telemetry: &SessionTelemetry,
|
||||
network_proxy_active: bool,
|
||||
@@ -457,17 +474,7 @@ impl Session {
|
||||
if reason == TurnAbortReason::Interrupted {
|
||||
self.cleanup_after_interrupt(&task.turn_context).await;
|
||||
|
||||
let marker = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!(
|
||||
"{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n</turn_aborted>"
|
||||
),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
let marker = interrupted_turn_history_marker();
|
||||
self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref())
|
||||
.await;
|
||||
self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)])
|
||||
|
||||
@@ -24,6 +24,9 @@ use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::truncation;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::tasks::interrupted_turn_history_marker;
|
||||
use codex_app_server_protocol::ThreadHistoryBuilder;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
#[cfg(test)]
|
||||
@@ -34,6 +37,8 @@ use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
@@ -126,6 +131,45 @@ pub struct NewThread {
|
||||
pub session_configured: SessionConfiguredEvent,
|
||||
}
|
||||
|
||||
// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once
|
||||
// core can represent sampling boundaries directly instead of relying on
|
||||
// whichever items happened to be persisted mid-turn.
|
||||
//
|
||||
// Two likely future variants:
|
||||
// - `TruncateToLastSamplingBoundary` for callers that want a coherent fork from
|
||||
// the last stable model boundary without synthesizing an interrupt.
|
||||
// - `WaitUntilNextSamplingBoundary` (or similar) for callers that prefer to
|
||||
// fork after the next sampling boundary rather than interrupting immediately.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ForkSnapshot {
|
||||
/// Fork a committed prefix ending strictly before the nth user message.
|
||||
///
|
||||
/// When `n` is within range, this cuts before that 0-based user-message
|
||||
/// boundary. When `n` is out of range and the source thread is currently
|
||||
/// mid-turn, this instead cuts before the active turn's opening boundary
|
||||
/// so the fork drops the unfinished turn suffix. When `n` is out of range
|
||||
/// and the source thread is already at a turn boundary, this returns the
|
||||
/// full committed history unchanged.
|
||||
TruncateBeforeNthUserMessage(usize),
|
||||
|
||||
/// Fork the current persisted history as if the source thread had been
|
||||
/// interrupted now.
|
||||
///
|
||||
/// If the persisted snapshot ends mid-turn, this appends the same
|
||||
/// `<turn_aborted>` marker produced by a real interrupt. If the snapshot is
|
||||
/// already at a turn boundary, this returns the current persisted history
|
||||
/// unchanged.
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
/// Preserve legacy `fork_thread(usize, ...)` callsites by mapping them to the
|
||||
/// existing truncate-before-nth-user-message snapshot mode.
|
||||
impl From<usize> for ForkSnapshot {
|
||||
fn from(value: usize) -> Self {
|
||||
Self::TruncateBeforeNthUserMessage(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct ThreadShutdownReport {
|
||||
pub completed: Vec<ThreadId>,
|
||||
@@ -541,20 +585,41 @@ impl ThreadManager {
|
||||
report
|
||||
}
|
||||
|
||||
/// Fork an existing thread by taking messages up to the given position (not including
|
||||
/// the message at the given position) and starting a new thread with identical
|
||||
/// configuration (unless overridden by the caller's `config`). The new thread will have
|
||||
/// a fresh id. Pass `usize::MAX` to keep the full rollout history.
|
||||
pub async fn fork_thread(
|
||||
/// Fork an existing thread by snapshotting rollout history according to
|
||||
/// `snapshot` and starting a new thread with identical configuration
|
||||
/// (unless overridden by the caller's `config`). The new thread will have
|
||||
/// a fresh id.
|
||||
pub async fn fork_thread<S>(
|
||||
&self,
|
||||
nth_user_message: usize,
|
||||
snapshot: S,
|
||||
config: Config,
|
||||
path: PathBuf,
|
||||
persist_extended_history: bool,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
) -> CodexResult<NewThread> {
|
||||
) -> CodexResult<NewThread>
|
||||
where
|
||||
S: Into<ForkSnapshot>,
|
||||
{
|
||||
let snapshot = snapshot.into();
|
||||
let history = RolloutRecorder::get_rollout_history(&path).await?;
|
||||
let history = truncate_before_nth_user_message(history, nth_user_message);
|
||||
let snapshot_state = snapshot_turn_state(&history);
|
||||
let history = match snapshot {
|
||||
ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message) => {
|
||||
truncate_before_nth_user_message(history, nth_user_message, &snapshot_state)
|
||||
}
|
||||
ForkSnapshot::Interrupted => {
|
||||
let history = match history {
|
||||
InitialHistory::New => InitialHistory::New,
|
||||
InitialHistory::Forked(history) => InitialHistory::Forked(history),
|
||||
InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history),
|
||||
};
|
||||
if snapshot_state.ends_mid_turn {
|
||||
append_interrupted_boundary(history, snapshot_state.active_turn_id)
|
||||
} else {
|
||||
history
|
||||
}
|
||||
}
|
||||
};
|
||||
Box::pin(self.state.spawn_thread(
|
||||
config,
|
||||
history,
|
||||
@@ -838,11 +903,31 @@ impl ThreadManagerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a prefix of `items` obtained by cutting strictly before the nth user message
|
||||
/// (0-based) and all items that follow it.
|
||||
fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory {
|
||||
/// Return a fork snapshot cut strictly before the nth user message (0-based).
|
||||
///
|
||||
/// Out-of-range values keep the full committed history at a turn boundary, but
|
||||
/// when the source thread is currently mid-turn they fall back to cutting
|
||||
/// before the active turn's opening boundary so the fork omits the unfinished
|
||||
/// suffix entirely.
|
||||
fn truncate_before_nth_user_message(
|
||||
history: InitialHistory,
|
||||
n: usize,
|
||||
snapshot_state: &SnapshotTurnState,
|
||||
) -> InitialHistory {
|
||||
let items: Vec<RolloutItem> = history.get_rollout_items();
|
||||
let rolled = truncation::truncate_rollout_before_nth_user_message_from_start(&items, n);
|
||||
let user_positions = truncation::user_message_positions_in_rollout(&items);
|
||||
let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() {
|
||||
if let Some(cut_idx) = snapshot_state
|
||||
.active_turn_start_index
|
||||
.or_else(|| user_positions.last().copied())
|
||||
{
|
||||
items[..cut_idx].to_vec()
|
||||
} else {
|
||||
items
|
||||
}
|
||||
} else {
|
||||
truncation::truncate_rollout_before_nth_user_message_from_start(&items, n)
|
||||
};
|
||||
|
||||
if rolled.is_empty() {
|
||||
InitialHistory::New
|
||||
@@ -851,6 +936,95 @@ fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> Initia
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct SnapshotTurnState {
|
||||
ends_mid_turn: bool,
|
||||
active_turn_id: Option<String>,
|
||||
active_turn_start_index: Option<usize>,
|
||||
}
|
||||
|
||||
fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState {
|
||||
let rollout_items = history.get_rollout_items();
|
||||
let mut builder = ThreadHistoryBuilder::new();
|
||||
for item in &rollout_items {
|
||||
builder.handle_rollout_item(item);
|
||||
}
|
||||
let active_turn_id = builder.active_turn_id_if_explicit();
|
||||
if builder.has_active_turn() && active_turn_id.is_some() {
|
||||
let active_turn_snapshot = builder.active_turn_snapshot();
|
||||
if active_turn_snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|turn| turn.status != TurnStatus::InProgress)
|
||||
{
|
||||
return SnapshotTurnState {
|
||||
ends_mid_turn: false,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
};
|
||||
}
|
||||
|
||||
return SnapshotTurnState {
|
||||
ends_mid_turn: true,
|
||||
active_turn_id,
|
||||
active_turn_start_index: builder.active_turn_start_index(),
|
||||
};
|
||||
}
|
||||
|
||||
let Some(last_user_position) = truncation::user_message_positions_in_rollout(&rollout_items)
|
||||
.last()
|
||||
.copied()
|
||||
else {
|
||||
return SnapshotTurnState {
|
||||
ends_mid_turn: false,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
};
|
||||
};
|
||||
|
||||
// Synthetic fork/resume histories can contain user/assistant response items
|
||||
// without explicit turn lifecycle events. If the persisted snapshot has no
|
||||
// terminating boundary after its last user message, treat it as mid-turn.
|
||||
SnapshotTurnState {
|
||||
ends_mid_turn: !rollout_items[last_user_position + 1..].iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_))
|
||||
)
|
||||
}),
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the same persisted interrupt boundary used by the live interrupt path
|
||||
/// to an existing fork snapshot after the source thread has been confirmed to
|
||||
/// be mid-turn.
|
||||
fn append_interrupted_boundary(history: InitialHistory, turn_id: Option<String>) -> InitialHistory {
|
||||
let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
turn_id,
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
}));
|
||||
|
||||
match history {
|
||||
InitialHistory::New => InitialHistory::Forked(vec![
|
||||
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
|
||||
aborted_event,
|
||||
]),
|
||||
InitialHistory::Forked(mut history) => {
|
||||
history.push(RolloutItem::ResponseItem(interrupted_turn_history_marker()));
|
||||
history.push(aborted_event);
|
||||
InitialHistory::Forked(history)
|
||||
}
|
||||
InitialHistory::Resumed(mut resumed) => {
|
||||
resumed
|
||||
.history
|
||||
.push(RolloutItem::ResponseItem(interrupted_turn_history_marker()));
|
||||
resumed.history.push(aborted_event);
|
||||
InitialHistory::Forked(resumed.history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_manager_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,11 +3,15 @@ use crate::codex::make_session_and_context;
|
||||
use crate::config::test_config;
|
||||
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::models_manager::manager::RefreshStrategy;
|
||||
use assert_matches::assert_matches;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::tasks::interrupted_turn_history_marker;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
@@ -38,7 +42,7 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_from_last_user_only() {
|
||||
fn truncates_before_requested_user_message() {
|
||||
let items = [
|
||||
user_msg("u1"),
|
||||
assistant_msg("a1"),
|
||||
@@ -68,7 +72,15 @@ fn drops_from_last_user_only() {
|
||||
.cloned()
|
||||
.map(RolloutItem::ResponseItem)
|
||||
.collect();
|
||||
let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1);
|
||||
let truncated = truncate_before_nth_user_message(
|
||||
InitialHistory::Forked(initial),
|
||||
1,
|
||||
&SnapshotTurnState {
|
||||
ends_mid_turn: false,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
},
|
||||
);
|
||||
let got_items = truncated.get_rollout_items();
|
||||
let expected_items = vec![
|
||||
RolloutItem::ResponseItem(items[0].clone()),
|
||||
@@ -85,8 +97,99 @@ fn drops_from_last_user_only() {
|
||||
.cloned()
|
||||
.map(RolloutItem::ResponseItem)
|
||||
.collect();
|
||||
let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2);
|
||||
assert_matches!(truncated2, InitialHistory::New);
|
||||
let truncated2 = truncate_before_nth_user_message(
|
||||
InitialHistory::Forked(initial2.clone()),
|
||||
2,
|
||||
&SnapshotTurnState {
|
||||
ends_mid_turn: false,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(truncated2.get_rollout_items()).unwrap(),
|
||||
serde_json::to_value(initial2).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() {
|
||||
let items = vec![
|
||||
RolloutItem::ResponseItem(user_msg("u1")),
|
||||
RolloutItem::ResponseItem(assistant_msg("a1")),
|
||||
RolloutItem::ResponseItem(user_msg("u2")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
];
|
||||
|
||||
let truncated = truncate_before_nth_user_message(
|
||||
InitialHistory::Forked(items.clone()),
|
||||
usize::MAX,
|
||||
&SnapshotTurnState {
|
||||
ends_mid_turn: true,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
|
||||
serde_json::to_value(items[..2].to_vec()).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_thread_accepts_legacy_usize_snapshot_argument() {
|
||||
fn assert_legacy_snapshot_callsite(
|
||||
manager: &ThreadManager,
|
||||
config: Config,
|
||||
path: std::path::PathBuf,
|
||||
) {
|
||||
let _future = manager.fork_thread(
|
||||
usize::MAX,
|
||||
config,
|
||||
path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_truncation_drops_pre_user_active_turn_prefix() {
|
||||
let items = vec![
|
||||
RolloutItem::ResponseItem(user_msg("u1")),
|
||||
RolloutItem::ResponseItem(assistant_msg("a1")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-2".to_string(),
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
})),
|
||||
RolloutItem::ResponseItem(user_msg("u2")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
];
|
||||
|
||||
let snapshot_state = snapshot_turn_state(&InitialHistory::Forked(items.clone()));
|
||||
assert_eq!(
|
||||
snapshot_state,
|
||||
SnapshotTurnState {
|
||||
ends_mid_turn: true,
|
||||
active_turn_id: Some("turn-2".to_string()),
|
||||
active_turn_start_index: Some(2),
|
||||
},
|
||||
);
|
||||
|
||||
let truncated = truncate_before_nth_user_message(
|
||||
InitialHistory::Forked(items.clone()),
|
||||
usize::MAX,
|
||||
&snapshot_state,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
|
||||
serde_json::to_value(items[..2].to_vec()).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -104,7 +207,15 @@ async fn ignores_session_prefix_messages_when_truncating() {
|
||||
.map(RolloutItem::ResponseItem)
|
||||
.collect();
|
||||
|
||||
let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1);
|
||||
let truncated = truncate_before_nth_user_message(
|
||||
InitialHistory::Forked(rollout_items),
|
||||
1,
|
||||
&SnapshotTurnState {
|
||||
ends_mid_turn: false,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
},
|
||||
);
|
||||
let got_items = truncated.get_rollout_items();
|
||||
|
||||
let expected: Vec<RolloutItem> = vec![
|
||||
@@ -185,3 +296,421 @@ async fn new_uses_configured_openai_provider_for_model_refresh() {
|
||||
let _ = manager.list_models(RefreshStrategy::Online).await;
|
||||
assert_eq!(models_mock.requests().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_fork_snapshot_appends_interrupt_boundary() {
|
||||
let committed_history =
|
||||
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
append_interrupted_boundary(committed_history, /*turn_id*/ None).get_rollout_items()
|
||||
)
|
||||
.expect("serialize interrupted fork history"),
|
||||
serde_json::to_value(vec![
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
turn_id: None,
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
})),
|
||||
])
|
||||
.expect("serialize expected interrupted fork history"),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
append_interrupted_boundary(InitialHistory::New, /*turn_id*/ None).get_rollout_items()
|
||||
)
|
||||
.expect("serialize interrupted empty fork history"),
|
||||
serde_json::to_value(vec![
|
||||
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
turn_id: None,
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
})),
|
||||
])
|
||||
.expect("serialize expected interrupted empty history"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_snapshot_is_not_mid_turn() {
|
||||
let interrupted_history = InitialHistory::Forked(vec![
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
})),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
snapshot_turn_state(&interrupted_history),
|
||||
SnapshotTurnState {
|
||||
ends_mid_turn: false,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_legacy_event_history_is_not_mid_turn() {
|
||||
let completed_history = InitialHistory::Forked(vec![
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello".to_string(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "done".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
})),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
snapshot_turn_state(&completed_history),
|
||||
SnapshotTurnState {
|
||||
ends_mid_turn: false,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_response_and_legacy_user_event_history_is_mid_turn() {
|
||||
let mixed_history = InitialHistory::Forked(vec![
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello".to_string(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
})),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
snapshot_turn_state(&mixed_history),
|
||||
SnapshotTurnState {
|
||||
ends_mid_turn: true,
|
||||
active_turn_id: None,
|
||||
active_turn_start_index: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let manager = ThreadManager::new(
|
||||
&config,
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
InitialHistory::Forked(vec![
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
]),
|
||||
auth_manager,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("create source thread from completed history");
|
||||
let source_path = source
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("source rollout path should exist");
|
||||
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
||||
.await
|
||||
.expect("read source rollout history");
|
||||
let source_snapshot_state = snapshot_turn_state(&source_history);
|
||||
assert!(source_snapshot_state.ends_mid_turn);
|
||||
let expected_turn_id = source_snapshot_state.active_turn_id.clone();
|
||||
assert_eq!(expected_turn_id, None);
|
||||
|
||||
let forked = manager
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
source_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("fork interrupted snapshot");
|
||||
let forked_path = forked
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("forked rollout path should exist");
|
||||
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
||||
.await
|
||||
.expect("read forked rollout history");
|
||||
assert!(!snapshot_turn_state(&history).ends_mid_turn);
|
||||
let rollout_items: Vec<_> = history
|
||||
.get_rollout_items()
|
||||
.into_iter()
|
||||
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
||||
.collect();
|
||||
let interrupted_marker_json =
|
||||
serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker()))
|
||||
.expect("serialize interrupted marker");
|
||||
let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg(
|
||||
EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
turn_id: expected_turn_id,
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
}),
|
||||
))
|
||||
.expect("serialize interrupted abort event");
|
||||
assert_eq!(
|
||||
rollout_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
serde_json::to_value(item).expect("serialize rollout item")
|
||||
== interrupted_marker_json
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
rollout_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
serde_json::to_value(item).expect("serialize rollout item")
|
||||
== interrupted_abort_json
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let manager = ThreadManager::new(
|
||||
&config,
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
InitialHistory::Forked(vec![
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-explicit".to_string(),
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
})),
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
]),
|
||||
auth_manager,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("create source thread from explicit partial history");
|
||||
let source_path = source
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("source rollout path should exist");
|
||||
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
||||
.await
|
||||
.expect("read source rollout history");
|
||||
let source_snapshot_state = snapshot_turn_state(&source_history);
|
||||
assert_eq!(
|
||||
source_snapshot_state,
|
||||
SnapshotTurnState {
|
||||
ends_mid_turn: true,
|
||||
active_turn_id: Some("turn-explicit".to_string()),
|
||||
active_turn_start_index: Some(1),
|
||||
},
|
||||
);
|
||||
|
||||
let forked = manager
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
source_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("fork interrupted snapshot");
|
||||
let forked_path = forked
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("forked rollout path should exist");
|
||||
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
||||
.await
|
||||
.expect("read forked rollout history");
|
||||
let rollout_items: Vec<_> = history
|
||||
.get_rollout_items()
|
||||
.into_iter()
|
||||
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
||||
.collect();
|
||||
|
||||
assert!(rollout_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
turn_id: Some(turn_id),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
})) if turn_id == "turn-explicit"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let manager = ThreadManager::new(
|
||||
&config,
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
InitialHistory::Forked(vec![
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
]),
|
||||
auth_manager,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("create source thread from partial history");
|
||||
let source_path = source
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("source rollout path should exist");
|
||||
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
||||
.await
|
||||
.expect("read source rollout history");
|
||||
assert!(snapshot_turn_state(&source_history).ends_mid_turn);
|
||||
manager.remove_thread(&source.thread_id).await;
|
||||
|
||||
let forked = manager
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config.clone(),
|
||||
source_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("fork interrupted snapshot");
|
||||
let forked_path = forked
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("forked rollout path should exist");
|
||||
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
||||
.await
|
||||
.expect("read forked rollout history");
|
||||
assert!(!snapshot_turn_state(&history).ends_mid_turn);
|
||||
|
||||
let forked_rollout_items: Vec<_> = history
|
||||
.get_rollout_items()
|
||||
.into_iter()
|
||||
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
||||
.collect();
|
||||
let interrupted_marker_json =
|
||||
serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker()))
|
||||
.expect("serialize interrupted marker");
|
||||
assert_eq!(
|
||||
forked_rollout_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
serde_json::to_value(item).expect("serialize forked rollout item")
|
||||
== interrupted_marker_json
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
|
||||
manager.remove_thread(&forked.thread_id).await;
|
||||
let reforked = manager
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
forked_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("re-fork interrupted snapshot");
|
||||
let reforked_path = reforked
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("re-forked rollout path should exist");
|
||||
let reforked_history = RolloutRecorder::get_rollout_history(&reforked_path)
|
||||
.await
|
||||
.expect("read re-forked rollout history");
|
||||
let reforked_rollout_items: Vec<_> = reforked_history
|
||||
.get_rollout_items()
|
||||
.into_iter()
|
||||
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
reforked_rollout_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
serde_json::to_value(item).expect("serialize re-forked rollout item")
|
||||
== interrupted_marker_json
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
reforked_rollout_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
..
|
||||
}))
|
||||
)
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ use crate::client_common::tools::ToolSpec;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::effective_file_system_sandbox_policy;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::tools::context::ApplyPatchToolOutput;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
@@ -35,6 +33,9 @@ use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
@@ -89,7 +90,7 @@ fn write_permissions_for_paths(file_paths: &[AbsolutePathBuf]) -> Option<Permiss
|
||||
..Default::default()
|
||||
})?;
|
||||
|
||||
crate::sandboxing::normalize_additional_permissions(permissions).ok()
|
||||
normalize_additional_permissions(permissions).ok()
|
||||
}
|
||||
|
||||
async fn effective_patch_permissions(
|
||||
|
||||
@@ -21,6 +21,9 @@ mod tool_suggest;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
|
||||
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
pub use plan::PLAN_TOOL;
|
||||
use serde::Deserialize;
|
||||
@@ -31,8 +34,6 @@ use std::path::PathBuf;
|
||||
use crate::codex::Session;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::sandboxing::normalize_additional_permissions;
|
||||
pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler;
|
||||
pub(crate) use crate::tools::code_mode::CodeModeWaitHandler;
|
||||
pub use apply_patch::ApplyPatchHandler;
|
||||
@@ -208,10 +209,8 @@ pub(super) async fn apply_granted_turn_permissions(
|
||||
);
|
||||
let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) {
|
||||
(Some(effective_permissions), Some(granted_permissions)) => {
|
||||
crate::sandboxing::intersect_permission_profiles(
|
||||
effective_permissions.clone(),
|
||||
granted_permissions,
|
||||
) == *effective_permissions
|
||||
intersect_permission_profiles(effective_permissions.clone(), granted_permissions)
|
||||
== *effective_permissions
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -9,16 +9,19 @@ use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::AgentStatus;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::FileSystemSandboxPolicy;
|
||||
use crate::protocol::NetworkSandboxPolicy;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionSource;
|
||||
use crate::protocol::SubAgentSource;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
@@ -32,6 +35,7 @@ use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -79,6 +83,29 @@ fn thread_manager() -> ThreadManager {
|
||||
)
|
||||
}
|
||||
|
||||
fn history_contains_inter_agent_communication(
|
||||
history_items: &[ResponseItem],
|
||||
expected: &InterAgentCommunication,
|
||||
) -> bool {
|
||||
history_items.iter().any(|item| {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
if role != "assistant" {
|
||||
return false;
|
||||
}
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
serde_json::from_str::<InterAgentCommunication>(text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
== Some(expected)
|
||||
}
|
||||
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NeverEndingTask;
|
||||
|
||||
@@ -130,6 +157,18 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListAgentsResult {
|
||||
agents: Vec<ListedAgentResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListedAgentResult {
|
||||
agent_name: String,
|
||||
agent_status: serde_json::Value,
|
||||
last_task_message: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_rejects_non_function_payloads() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
@@ -385,52 +424,226 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
&& communication.content == "continue"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = (*turn.config).clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let spawn_output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _ = expect_text_output(spawn_output);
|
||||
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker path should resolve");
|
||||
let child_thread = manager
|
||||
.get_thread(child_thread_id)
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
.expect("child thread should exist");
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = child_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == "continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
if recorded && !saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("v2 send_input should record assistant envelope");
|
||||
let child_turn = child_thread.codex.session.new_default_turn().await;
|
||||
child_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
child_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: child_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let output = ListAgentsHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"list_agents",
|
||||
function_payload(json!({})),
|
||||
))
|
||||
.await
|
||||
.expect("list_agents should succeed");
|
||||
let (content, success) = expect_text_output(output);
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert_eq!(result.agents.len(), 1);
|
||||
assert_eq!(result.agents[0].agent_name, "/root/worker");
|
||||
assert_eq!(result.agents[0].agent_status, json!({"completed": "done"}));
|
||||
assert_eq!(
|
||||
result.agents[0].last_task_message.as_deref(),
|
||||
Some("inspect this repo")
|
||||
);
|
||||
assert_eq!(success, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = (*turn.config).clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config.clone());
|
||||
|
||||
let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path");
|
||||
let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path");
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
config.clone(),
|
||||
vec![UserInput::Text {
|
||||
text: "research".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(researcher_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
crate::agent::control::SpawnAgentOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("researcher agent should spawn");
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
config,
|
||||
vec![UserInput::Text {
|
||||
text: "build".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
crate::agent::control::SpawnAgentOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("worker agent should spawn");
|
||||
|
||||
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(researcher_path),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
|
||||
let output = ListAgentsHandlerV2
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"list_agents",
|
||||
function_payload(json!({
|
||||
"path_prefix": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("list_agents should succeed");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert_eq!(result.agents.len(), 1);
|
||||
assert_eq!(result.agents[0].agent_name, worker_path.as_str());
|
||||
assert_eq!(result.agents[0].last_task_message.as_deref(), Some("build"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_list_agents_omits_closed_agents() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = (*turn.config).clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let spawn_output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _ = expect_text_output(spawn_output);
|
||||
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker path should resolve");
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.close_agent(agent_id)
|
||||
.await
|
||||
.expect("close_agent should succeed");
|
||||
|
||||
let output = ListAgentsHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"list_agents",
|
||||
function_payload(json!({})),
|
||||
))
|
||||
.await
|
||||
.expect("list_agents should succeed");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert!(result.agents.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -467,10 +680,6 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker should resolve");
|
||||
let thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
.expect("worker thread should exist");
|
||||
let invocation = invocation(
|
||||
session,
|
||||
turn,
|
||||
@@ -507,55 +716,6 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
.into_iter()
|
||||
.find(|(id, op)| *id == agent_id && *op == expected);
|
||||
assert_eq!(captured, Some((agent_id, expected)));
|
||||
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded_assistant_envelope = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text }
|
||||
if text == "read the folder"
|
||||
|| text == "[mention:$drive](app://google_drive)\nread the folder"
|
||||
))
|
||||
)
|
||||
});
|
||||
if !recorded_assistant_envelope && saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("structured items should stay on the legacy user-input path");
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -659,19 +819,15 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let saw_envelope = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_envelope = history_contains_inter_agent_communication(
|
||||
&history_items,
|
||||
&InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"continue".to_string(),
|
||||
),
|
||||
);
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
@@ -1392,7 +1548,7 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() {
|
||||
assert_eq!(
|
||||
result,
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
status: HashMap::from([(target, AgentStatus::NotFound)]),
|
||||
message: "Wait completed.".to_string(),
|
||||
timed_out: false,
|
||||
}
|
||||
);
|
||||
@@ -1560,12 +1716,7 @@ async fn wait_agent_returns_final_status_without_timeout() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
task_name: String,
|
||||
}
|
||||
|
||||
async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
@@ -1595,9 +1746,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let (content, _) = expect_text_output(spawn_output);
|
||||
let spawn_result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn result should parse");
|
||||
let _ = expect_text_output(spawn_output);
|
||||
|
||||
let agent_id = session
|
||||
.services
|
||||
@@ -1645,13 +1794,67 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
assert_eq!(
|
||||
result,
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]),
|
||||
message: "Wait completed.".to_string(),
|
||||
timed_out: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(success, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
turn.config = Arc::new(config.clone());
|
||||
|
||||
let thread = manager.start_thread(config).await.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
let child_turn = thread.thread.codex.session.new_default_turn().await;
|
||||
thread
|
||||
.thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
child_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: child_turn.sub_id.clone(),
|
||||
last_agent_message: Some("sensitive child output".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let output = WaitAgentHandlerV2
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"wait_agent",
|
||||
function_payload(json!({
|
||||
"targets": [agent_id.to_string()],
|
||||
"timeout_ms": 1000
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("wait_agent should succeed");
|
||||
let (content, success) = expect_text_output(output);
|
||||
let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult =
|
||||
serde_json::from_str(&content).expect("wait_agent result should be json");
|
||||
assert_eq!(
|
||||
result,
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
message: "Wait completed.".to_string(),
|
||||
timed_out: false,
|
||||
}
|
||||
);
|
||||
assert!(!content.contains("sensitive child output"));
|
||||
assert_eq!(success, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_agent_submits_shutdown_and_returns_previous_status() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
|
||||
@@ -30,10 +30,12 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(crate) use list_agents::Handler as ListAgentsHandler;
|
||||
pub(crate) use send_input::Handler as SendInputHandler;
|
||||
pub(crate) use spawn::Handler as SpawnAgentHandler;
|
||||
pub(crate) use wait::Handler as WaitAgentHandler;
|
||||
|
||||
mod list_agents;
|
||||
mod send_input;
|
||||
mod spawn;
|
||||
pub(crate) mod wait;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
use crate::agent::control::ListedAgent;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = ListAgentsResult;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: ListAgentsArgs = parse_arguments(&arguments)?;
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.register_session_root(session.conversation_id, &turn.session_source);
|
||||
let agents = session
|
||||
.services
|
||||
.agent_control
|
||||
.list_agents(&turn.session_source, args.path_prefix.as_deref())
|
||||
.await
|
||||
.map_err(collab_spawn_error)?;
|
||||
|
||||
Ok(ListAgentsResult { agents })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListAgentsArgs {
|
||||
path_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ListAgentsResult {
|
||||
agents: Vec<ListedAgent>,
|
||||
}
|
||||
|
||||
impl ToolOutput for ListAgentsResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "list_agents")
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
tool_output_response_item(call_id, payload, self, Some(true), "list_agents")
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
tool_output_code_mode_result(self, "list_agents")
|
||||
}
|
||||
}
|
||||
@@ -35,21 +35,12 @@ impl ToolHandler for Handler {
|
||||
let args: WaitArgs = parse_arguments(&arguments)?;
|
||||
let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?;
|
||||
let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len());
|
||||
let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len());
|
||||
for receiver_thread_id in &receiver_thread_ids {
|
||||
let agent_metadata = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_metadata(*receiver_thread_id)
|
||||
.unwrap_or_default();
|
||||
target_by_thread_id.insert(
|
||||
*receiver_thread_id,
|
||||
agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| receiver_thread_id.to_string()),
|
||||
);
|
||||
receiver_agents.push(CollabAgentRef {
|
||||
thread_id: *receiver_thread_id,
|
||||
agent_nickname: agent_metadata.agent_nickname,
|
||||
@@ -152,18 +143,7 @@ impl ToolHandler for Handler {
|
||||
let timed_out = statuses.is_empty();
|
||||
let statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
|
||||
let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents);
|
||||
let result = WaitAgentResult {
|
||||
status: statuses
|
||||
.into_iter()
|
||||
.filter_map(|(thread_id, status)| {
|
||||
target_by_thread_id
|
||||
.get(&thread_id)
|
||||
.cloned()
|
||||
.map(|target| (target, status))
|
||||
})
|
||||
.collect(),
|
||||
timed_out,
|
||||
};
|
||||
let result = WaitAgentResult::from_timed_out(timed_out);
|
||||
|
||||
session
|
||||
.send_event(
|
||||
@@ -191,10 +171,24 @@ struct WaitArgs {
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct WaitAgentResult {
|
||||
pub(crate) status: HashMap<String, AgentStatus>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) timed_out: bool,
|
||||
}
|
||||
|
||||
impl WaitAgentResult {
|
||||
fn from_timed_out(timed_out: bool) -> Self {
|
||||
let message = if timed_out {
|
||||
"Wait timed out."
|
||||
} else {
|
||||
"Wait completed."
|
||||
};
|
||||
Self {
|
||||
message: message.to_string(),
|
||||
timed_out,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolOutput for WaitAgentResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "wait_agent")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::request_permissions::RequestPermissionsArgs;
|
||||
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::normalize_additional_permissions;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::hook_runtime::run_pre_tool_use_hooks;
|
||||
use crate::memories::usage::emit_metric_for_tool_read;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::sandbox_tags::sandbox_tag;
|
||||
@@ -20,7 +21,10 @@ use codex_hooks::HookToolInput;
|
||||
use codex_hooks::HookToolInputLocalShell;
|
||||
use codex_hooks::HookToolKind;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -243,6 +247,20 @@ impl ToolRegistry {
|
||||
return Err(FunctionCallError::Fatal(message));
|
||||
}
|
||||
|
||||
if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload)
|
||||
&& let Some(reason) = run_pre_tool_use_hooks(
|
||||
&invocation.session,
|
||||
&invocation.turn,
|
||||
invocation.call_id.clone(),
|
||||
command.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"Bash command blocked by hook: {reason}. Command: {command}"
|
||||
)));
|
||||
}
|
||||
|
||||
let is_mutating = handler.is_mutating(&invocation).await;
|
||||
let response_cell = tokio::sync::Mutex::new(None);
|
||||
let invocation_for_tool = invocation.clone();
|
||||
@@ -413,6 +431,35 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PreToolUseExecCommandArgs {
|
||||
cmd: String,
|
||||
}
|
||||
|
||||
fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option<String> {
|
||||
match (tool_name, payload) {
|
||||
("shell" | "container.exec", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<ShellToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command))
|
||||
}
|
||||
("local_shell", ToolPayload::LocalShell { params }) => Some(
|
||||
codex_shell_command::parse_command::shlex_join(¶ms.command),
|
||||
),
|
||||
("shell_command", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<ShellCommandToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| params.command)
|
||||
}
|
||||
("exec_command", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<PreToolUseExecCommandArgs>(arguments)
|
||||
.ok()
|
||||
.map(|params| params.cmd)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks use a separate wire-facing input type so hook payload JSON stays stable
|
||||
// and decoupled from core's internal tool runtime representation.
|
||||
impl From<&ToolPayload> for HookToolInput {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
struct TestHandler;
|
||||
@@ -48,3 +50,63 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
|
||||
.is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_uses_raw_shell_command_input() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "command": "printf shell command" }).to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("shell_command", &payload),
|
||||
Some("printf shell command".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_shell_joins_vector_input() {
|
||||
let payload = ToolPayload::LocalShell {
|
||||
params: ShellToolCallParams {
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printf hi".to_string(),
|
||||
],
|
||||
workdir: None,
|
||||
timeout_ms: None,
|
||||
sandbox_permissions: None,
|
||||
prefix_rule: None,
|
||||
additional_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("local_shell", &payload),
|
||||
Some("bash -lc 'printf hi'".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_uses_raw_exec_command_input() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("exec_command", &payload),
|
||||
Some("printf exec command".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_skips_non_shell_tools() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"plan": [{ "step": "watch the tide", "status": "pending" }]
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(pre_tool_use_command("update_plan", &payload), None);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ use crate::protocol::GranularApprovalConfig;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use crate::skills::SkillMetadata;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
@@ -40,6 +38,8 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
|
||||
@@ -167,6 +167,39 @@ fn send_input_output_schema() -> JsonValue {
|
||||
})
|
||||
}
|
||||
|
||||
fn list_agents_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Canonical task name for the agent when available, otherwise the agent id."
|
||||
},
|
||||
"agent_status": {
|
||||
"description": "Last known status of the agent.",
|
||||
"allOf": [agent_status_output_schema()]
|
||||
},
|
||||
"last_task_message": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Most recent user or inter-agent instruction received by the agent, when available."
|
||||
}
|
||||
},
|
||||
"required": ["agent_name", "agent_status", "last_task_message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Live agents visible in the current root thread tree."
|
||||
}
|
||||
},
|
||||
"required": ["agents"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn resume_agent_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -178,7 +211,7 @@ fn resume_agent_output_schema() -> JsonValue {
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_output_schema() -> JsonValue {
|
||||
fn wait_output_schema_v1() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -197,6 +230,24 @@ fn wait_output_schema() -> JsonValue {
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_output_schema_v2() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Brief wait summary without the agent's final content."
|
||||
},
|
||||
"timed_out": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
|
||||
}
|
||||
},
|
||||
"required": ["message", "timed_out"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn close_agent_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -1422,7 +1473,7 @@ fn create_resume_agent_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_wait_agent_tool() -> ToolSpec {
|
||||
fn wait_agent_tool_parameters() -> JsonSchema {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"targets".to_string(),
|
||||
@@ -1443,18 +1494,60 @@ fn create_wait_agent_tool() -> ToolSpec {
|
||||
},
|
||||
);
|
||||
|
||||
JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["targets".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_wait_agent_tool_v1() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "wait_agent".to_string(),
|
||||
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: wait_agent_tool_parameters(),
|
||||
output_schema: Some(wait_output_schema_v1()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_wait_agent_tool_v2() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "wait_agent".to_string(),
|
||||
description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: wait_agent_tool_parameters(),
|
||||
output_schema: Some(wait_output_schema_v2()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_list_agents_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([(
|
||||
"path_prefix".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
)]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "list_agents".to_string(),
|
||||
description: "List live agents in the current root thread tree. Optionally filter by task-path prefix."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["targets".to_string()]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(wait_output_schema()),
|
||||
output_schema: Some(list_agents_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2602,6 +2695,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::multi_agents::SendInputHandler;
|
||||
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
|
||||
use crate::tools::handlers::multi_agents::WaitAgentHandler;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
@@ -3006,7 +3100,11 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
}
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_wait_agent_tool(),
|
||||
if config.multi_agent_v2 {
|
||||
create_wait_agent_tool_v2()
|
||||
} else {
|
||||
create_wait_agent_tool_v1()
|
||||
},
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
@@ -3017,9 +3115,16 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
if config.multi_agent_v2 {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_list_agents_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandlerV2));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2));
|
||||
builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2));
|
||||
} else {
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandler));
|
||||
|
||||
@@ -469,7 +469,11 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
create_view_image_tool(config.can_request_original_image_detail),
|
||||
create_spawn_agent_tool(&config),
|
||||
create_send_input_tool(),
|
||||
create_wait_agent_tool(),
|
||||
if config.multi_agent_v2 {
|
||||
create_wait_agent_tool_v2()
|
||||
} else {
|
||||
create_wait_agent_tool_v1()
|
||||
},
|
||||
create_close_agent_tool(),
|
||||
] {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
@@ -521,6 +525,7 @@ fn test_build_specs_collab_tools_enabled() {
|
||||
&["spawn_agent", "send_input", "wait_agent", "close_agent"],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "spawn_agents_on_csv");
|
||||
assert_lacks_tool_name(&tools, "list_agents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -541,6 +546,16 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
],
|
||||
);
|
||||
|
||||
let spawn_agent = find_tool(&tools, "spawn_agent");
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
@@ -607,8 +622,35 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
.as_ref()
|
||||
.expect("wait_agent should define output schema");
|
||||
assert_eq!(
|
||||
output_schema["properties"]["status"]["description"],
|
||||
json!("Final statuses keyed by canonical task name when available, otherwise by agent id.")
|
||||
output_schema["properties"]["message"]["description"],
|
||||
json!("Brief wait summary without the agent's final content.")
|
||||
);
|
||||
|
||||
let list_agents = find_tool(&tools, "list_agents");
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
parameters,
|
||||
output_schema,
|
||||
..
|
||||
}) = &list_agents.spec
|
||||
else {
|
||||
panic!("list_agents should be a function tool");
|
||||
};
|
||||
let JsonSchema::Object {
|
||||
properties,
|
||||
required,
|
||||
..
|
||||
} = parameters
|
||||
else {
|
||||
panic!("list_agents should use object params");
|
||||
};
|
||||
assert!(properties.contains_key("path_prefix"));
|
||||
assert_eq!(required.as_ref(), None);
|
||||
let output_schema = output_schema
|
||||
.as_ref()
|
||||
.expect("list_agents should define output schema");
|
||||
assert_eq!(
|
||||
output_schema["properties"]["agents"]["items"]["required"],
|
||||
json!(["agent_name", "agent_status", "last_task_message"])
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "resume_agent");
|
||||
}
|
||||
|
||||
@@ -723,6 +723,7 @@ impl TestCodex {
|
||||
final_output_json_schema: None,
|
||||
cwd: self.config.cwd.clone(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -310,6 +310,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -919,6 +920,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1000,6 +1002,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1151,6 +1154,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1215,6 +1219,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1287,6 +1292,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1359,6 +1365,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
|
||||
@@ -558,6 +558,7 @@ async fn submit_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -1310,6 +1310,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_configured.model.clone(),
|
||||
effort: Some(ReasoningEffort::Low),
|
||||
@@ -1427,6 +1428,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default()
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_configured.model,
|
||||
effort: None,
|
||||
|
||||
@@ -2319,6 +2319,7 @@ text(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -178,6 +178,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> {
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -293,6 +294,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -1658,6 +1658,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
|
||||
final_output_json_schema: None,
|
||||
cwd: resumed.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: resumed.session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -1748,6 +1749,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
@@ -1772,6 +1774,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
@@ -1882,6 +1885,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: initial.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
@@ -1930,6 +1934,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: resumed.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
@@ -3132,6 +3137,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
@@ -3156,6 +3162,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
|
||||
@@ -16,6 +16,9 @@ use codex_core::ThreadManager;
|
||||
use codex_core::compact::SUMMARIZATION_PROMPT;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
@@ -380,20 +383,35 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index];
|
||||
let summary_after_second_compact =
|
||||
extract_summary_user_text(&requests[requests.len() - 3], SUMMARY_TEXT);
|
||||
let mut expected_after_second_compact_user_texts =
|
||||
vec!["AFTER_FORK".to_string(), summary_after_second_compact];
|
||||
let mut expected_after_second_compact_user_texts = vec![
|
||||
"hello world".to_string(),
|
||||
"AFTER_COMPACT".to_string(),
|
||||
"AFTER_RESUME".to_string(),
|
||||
"AFTER_FORK".to_string(),
|
||||
summary_after_second_compact,
|
||||
];
|
||||
expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix);
|
||||
expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string());
|
||||
let mut expected_fork_local_user_texts = vec![
|
||||
"AFTER_FORK".to_string(),
|
||||
expected_after_second_compact_user_texts[4].clone(),
|
||||
];
|
||||
expected_fork_local_user_texts.extend_from_slice(seeded_user_prefix);
|
||||
expected_fork_local_user_texts.push("AFTER_COMPACT_2".to_string());
|
||||
let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user");
|
||||
let (final_last, final_prefix) = final_user_texts
|
||||
.split_last()
|
||||
.unwrap_or_else(|| panic!("after-second-resume request missing user messages"));
|
||||
assert_eq!(final_last, AFTER_SECOND_RESUME);
|
||||
assert!(
|
||||
final_prefix.starts_with(&expected_after_second_compact_user_texts),
|
||||
"after-second-resume user texts should preserve post-compact user history prefix"
|
||||
);
|
||||
let final_seeded_suffix = &final_prefix[expected_after_second_compact_user_texts.len()..];
|
||||
let matched_prefix_len = if final_prefix.starts_with(&expected_after_second_compact_user_texts)
|
||||
{
|
||||
expected_after_second_compact_user_texts.len()
|
||||
} else if final_prefix.starts_with(&expected_fork_local_user_texts) {
|
||||
expected_fork_local_user_texts.len()
|
||||
} else {
|
||||
panic!("after-second-resume user texts should preserve post-compact user history prefix");
|
||||
};
|
||||
let final_seeded_suffix = &final_prefix[matched_prefix_len..];
|
||||
if seeded_user_prefix.is_empty() {
|
||||
assert!(
|
||||
final_seeded_suffix.is_empty(),
|
||||
@@ -502,6 +520,143 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
/// Scenario: rolling back a turn that introduced persistent pre-turn context
|
||||
/// diffs currently duplicates those context updates on the next request.
|
||||
async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<()> {
|
||||
if network_disabled() {
|
||||
println!("Skipping test because network is disabled in this sandbox");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
const MODEL: &str = "gpt-5.1-codex";
|
||||
const TURN_ONE_USER: &str = "turn 1 user";
|
||||
const TURN_TWO_USER: &str = "turn 2 user";
|
||||
const FOLLOWUP_USER: &str = "follow-up user";
|
||||
const ROLLED_BACK_DEV_INSTRUCTIONS: &str = "ROLLED_BACK_DEV_INSTRUCTIONS";
|
||||
const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_assistant_message("m1", "turn 1 assistant"),
|
||||
ev_completed("r1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("m2", "turn 2 assistant"),
|
||||
ev_completed("r2"),
|
||||
]),
|
||||
sse(vec![ev_completed("r3")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let (_home, config, _manager, conversation) =
|
||||
start_test_conversation(&server, Some(MODEL)).await;
|
||||
|
||||
user_turn(&conversation, TURN_ONE_USER).await;
|
||||
|
||||
let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD);
|
||||
std::fs::create_dir_all(&override_cwd)?;
|
||||
conversation
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: Some(override_cwd),
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: None,
|
||||
windows_sandbox_level: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: MODEL.to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()),
|
||||
},
|
||||
}),
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
user_turn(&conversation, TURN_TWO_USER).await;
|
||||
|
||||
conversation
|
||||
.submit(Op::ThreadRollback { num_turns: 1 })
|
||||
.await?;
|
||||
let rollback_event = wait_for_event(&conversation, |ev| {
|
||||
matches!(ev, EventMsg::ThreadRolledBack(_))
|
||||
})
|
||||
.await;
|
||||
let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else {
|
||||
panic!("expected thread rolled back event");
|
||||
};
|
||||
assert_eq!(rollback_event.num_turns, 1);
|
||||
|
||||
user_turn(&conversation, FOLLOWUP_USER).await;
|
||||
|
||||
let requests = request_log.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.message_input_texts("user")
|
||||
.iter()
|
||||
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests[2]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
|
||||
let after_rollback_user_texts = requests[2].message_input_texts("user");
|
||||
assert_eq!(
|
||||
after_rollback_user_texts
|
||||
.iter()
|
||||
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
after_rollback_user_texts.last().map(String::as_str),
|
||||
Some(FOLLOWUP_USER)
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(
|
||||
"rollback_followup_turn_duplicates_context_updates",
|
||||
context_snapshot::format_labeled_requests_snapshot(
|
||||
"rollback currently duplicates pre-turn override context updates on the follow-up request",
|
||||
&[
|
||||
("rolled-back turn request", &requests[1]),
|
||||
("follow-up request after rollback", &requests[2]),
|
||||
],
|
||||
&ContextSnapshotOptions::default()
|
||||
.strip_capability_instructions()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }),
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_line_endings(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ async fn submit_user_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -131,6 +132,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use codex_core::ForkSnapshot;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::parse_turn_item;
|
||||
use codex_protocol::items::TurnItem;
|
||||
@@ -110,7 +111,13 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
thread: codex_fork1,
|
||||
..
|
||||
} = thread_manager
|
||||
.fork_thread(1, config_for_fork.clone(), base_path.clone(), false, None)
|
||||
.fork_thread(
|
||||
ForkSnapshot::TruncateBeforeNthUserMessage(1),
|
||||
config_for_fork.clone(),
|
||||
base_path.clone(),
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("fork 1");
|
||||
|
||||
@@ -129,7 +136,13 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
thread: codex_fork2,
|
||||
..
|
||||
} = thread_manager
|
||||
.fork_thread(0, config_for_fork.clone(), fork1_path.clone(), false, None)
|
||||
.fork_thread(
|
||||
ForkSnapshot::TruncateBeforeNthUserMessage(0),
|
||||
config_for_fork.clone(),
|
||||
fork1_path.clone(),
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("fork 2");
|
||||
|
||||
|
||||
@@ -174,6 +174,69 @@ if payload.get("prompt") == {blocked_prompt_json}:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_pre_tool_use_hook(
|
||||
home: &Path,
|
||||
matcher: Option<&str>,
|
||||
mode: &str,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
let script_path = home.join("pre_tool_use_hook.py");
|
||||
let log_path = home.join("pre_tool_use_hook_log.jsonl");
|
||||
let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?;
|
||||
let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?;
|
||||
let script = format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
log_path = Path(r"{log_path}")
|
||||
mode = {mode_json}
|
||||
reason = {reason_json}
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
|
||||
if mode == "json_deny":
|
||||
print(json.dumps({{
|
||||
"hookSpecificOutput": {{
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason
|
||||
}}
|
||||
}}))
|
||||
elif mode == "exit_2":
|
||||
sys.stderr.write(reason + "\n")
|
||||
raise SystemExit(2)
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
mode_json = mode_json,
|
||||
reason_json = reason_json,
|
||||
);
|
||||
|
||||
let mut group = serde_json::json!({
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": format!("python3 {}", script_path.display()),
|
||||
"statusMessage": "running pre tool use hook",
|
||||
}]
|
||||
});
|
||||
if let Some(matcher) = matcher {
|
||||
group["matcher"] = Value::String(matcher.to_string());
|
||||
}
|
||||
|
||||
let hooks = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [group]
|
||||
}
|
||||
});
|
||||
|
||||
fs::write(&script_path, script).context("write pre tool use hook script")?;
|
||||
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> {
|
||||
let script_path = home.join("session_start_hook.py");
|
||||
let log_path = home.join("session_start_hook_log.jsonl");
|
||||
@@ -253,6 +316,15 @@ fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl"))
|
||||
.context("read pre tool use hook log")?
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str(line).context("parse pre tool use hook log line"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_session_start_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
fs::read_to_string(home.join("session_start_hook_log.jsonl"))
|
||||
.context("read session start hook log")?
|
||||
@@ -849,3 +921,357 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-shell-command";
|
||||
let marker = std::env::temp_dir().join("pretooluse-shell-command-marker");
|
||||
let command = format!("printf blocked > {}", marker.display());
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "hook blocked it"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover pre tool use marker")?;
|
||||
}
|
||||
|
||||
test.submit_turn_with_policy(
|
||||
"run the blocked shell command",
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked by pre hook"),
|
||||
"blocked tool output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
output.contains(&format!("Command: {command}")),
|
||||
"blocked tool output should surface the blocked command",
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"blocked command should not create marker file"
|
||||
);
|
||||
|
||||
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
||||
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
let transcript_path = hook_inputs[0]["transcript_path"]
|
||||
.as_str()
|
||||
.expect("pre tool use hook transcript_path");
|
||||
assert!(
|
||||
!transcript_path.is_empty(),
|
||||
"pre tool use hook should receive a non-empty transcript_path",
|
||||
);
|
||||
assert!(
|
||||
Path::new(transcript_path).exists(),
|
||||
"pre tool use hook transcript_path should be materialized on disk",
|
||||
);
|
||||
assert!(
|
||||
hook_inputs[0]["turn_id"]
|
||||
.as_str()
|
||||
.is_some_and(|turn_id| !turn_id.is_empty())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-local-shell";
|
||||
let marker = std::env::temp_dir().join("pretooluse-local-shell-marker");
|
||||
let command = vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("printf blocked > {}", marker.display()),
|
||||
];
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_local_shell_call(
|
||||
call_id,
|
||||
"completed",
|
||||
command.iter().map(String::as_str).collect(),
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "local shell blocked"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover local shell marker")?;
|
||||
}
|
||||
|
||||
test.submit_turn("run the blocked local shell command")
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("local shell output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked local shell"),
|
||||
"blocked local shell output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
output.contains(&format!(
|
||||
"Command: {}",
|
||||
codex_shell_command::parse_command::shlex_join(&command)
|
||||
)),
|
||||
"blocked local shell output should surface the blocked command",
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"blocked local shell command should not execute"
|
||||
);
|
||||
|
||||
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_input"]["command"],
|
||||
codex_shell_command::parse_command::shlex_join(&command),
|
||||
);
|
||||
assert!(
|
||||
hook_inputs[0]["turn_id"]
|
||||
.as_str()
|
||||
.is_some_and(|turn_id| !turn_id.is_empty())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-exec-command";
|
||||
let marker = std::env::temp_dir().join("pretooluse-exec-command-marker");
|
||||
let command = format!("printf blocked > {}", marker.display());
|
||||
let args = serde_json::json!({ "cmd": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"exec_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "exec command blocked"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover exec marker")?;
|
||||
}
|
||||
|
||||
test.submit_turn("run the blocked exec command").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("exec command output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked exec command"),
|
||||
"blocked exec command output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
output.contains(&format!("Command: {command}")),
|
||||
"blocked exec command output should surface the blocked command",
|
||||
);
|
||||
assert!(!marker.exists(), "blocked exec command should not execute");
|
||||
|
||||
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
assert!(
|
||||
hook_inputs[0]["turn_id"]
|
||||
.as_str()
|
||||
.is_some_and(|turn_id| !turn_id.is_empty())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-update-plan";
|
||||
let args = serde_json::json!({
|
||||
"plan": [{
|
||||
"step": "watch the tide",
|
||||
"status": "pending",
|
||||
}]
|
||||
});
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"update_plan",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "plan updated"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) = write_pre_tool_use_hook(home, None, "json_deny", "should not fire")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("update the plan").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("update plan output string");
|
||||
assert!(
|
||||
!output.contains("should not fire"),
|
||||
"non-shell tool output should not be blocked by PreToolUse",
|
||||
);
|
||||
|
||||
let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl");
|
||||
assert!(
|
||||
!hook_log_path.exists(),
|
||||
"non-shell tools should not trigger pre tool use hooks",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -205,6 +206,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -522,6 +522,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -598,6 +599,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -706,6 +708,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -892,6 +895,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -1005,6 +1009,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -80,6 +80,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
|
||||
final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str)
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -126,6 +126,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -162,6 +163,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: next_model.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -221,6 +223,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -257,6 +260,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: next_model.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -398,6 +402,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -525,6 +531,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -545,6 +552,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -655,6 +663,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -675,6 +684,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -787,6 +797,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history()
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -815,6 +826,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history()
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -969,6 +981,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: large_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -1027,6 +1040,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: smaller_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
|
||||
@@ -121,6 +121,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -144,6 +145,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: preturn_context_diff_cwd,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -222,6 +224,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd_one.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -245,6 +248,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd_two,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -354,6 +358,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: resume_override_cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: resumed.session_configured.model.clone(),
|
||||
effort: resumed.config.model_reasoning_effort,
|
||||
|
||||
@@ -96,6 +96,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -973,6 +973,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
.features
|
||||
.disable(Feature::GhostCommit)
|
||||
.expect("test config should allow feature update");
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -989,7 +990,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
logs_assert(|lines: &[&str]| {
|
||||
let line = lines
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::ForkSnapshot;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
@@ -419,7 +420,13 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
let forked = initial
|
||||
.thread_manager
|
||||
.fork_thread(usize::MAX, fork_config, rollout_path, false, None)
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
fork_config,
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await?;
|
||||
forked
|
||||
.thread
|
||||
|
||||
@@ -103,6 +103,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -153,6 +154,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -210,6 +212,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -273,6 +276,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -324,6 +328,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -361,6 +366,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -427,6 +433,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -543,6 +551,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -580,6 +589,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -696,6 +706,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -814,6 +825,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -851,6 +863,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
|
||||
@@ -703,6 +703,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
}],
|
||||
cwd: new_cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: new_policy.clone(),
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
@@ -815,6 +816,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
@@ -835,6 +837,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
@@ -939,6 +942,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
@@ -959,6 +963,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
|
||||
@@ -173,6 +173,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: requested_model.to_string(),
|
||||
effort: None,
|
||||
@@ -231,6 +232,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning()
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: requested_model.to_string(),
|
||||
effort: None,
|
||||
@@ -394,6 +396,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REMOTE_MODEL_SLUG.to_string(),
|
||||
effort: None,
|
||||
@@ -612,6 +615,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: model.to_string(),
|
||||
effort: None,
|
||||
|
||||
@@ -193,6 +193,7 @@ async fn submit_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -145,6 +145,7 @@ async fn submit_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -138,6 +138,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -255,6 +256,7 @@ where
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -129,6 +129,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -298,6 +299,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -501,6 +503,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_only_model_slug.to_string(),
|
||||
effort: None,
|
||||
@@ -615,6 +618,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -776,6 +780,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1022,6 +1027,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -45,6 +45,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() ->
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -143,6 +144,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -228,6 +230,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -277,6 +280,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
|
||||
@@ -72,7 +72,7 @@ async fn wait_for_snapshot(codex_home: &Path) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
async fn wait_for_file_contents(path: &Path) -> Result<String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
loop {
|
||||
match fs::read_to_string(path).await {
|
||||
Ok(contents) => return Ok(contents),
|
||||
@@ -164,6 +164,7 @@ async fn run_snapshot_command_with_options(
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -254,6 +255,7 @@ async fn run_shell_command_snapshot_with_options(
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -324,6 +326,7 @@ async fn run_tool_turn_on_harness(
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -528,7 +531,9 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Add File: snapshot-apply.txt\n+hello from snapshot\n*** End Patch\nEOF\n";
|
||||
let args = json!({
|
||||
"command": script,
|
||||
"timeout_ms": 1_000,
|
||||
// The intercepted apply_patch path self-invokes codex, which can take
|
||||
// longer than a second in Bazel macOS test environments.
|
||||
"timeout_ms": 5_000,
|
||||
});
|
||||
let call_id = "shell-snapshot-apply-patch";
|
||||
let responses = vec![
|
||||
@@ -555,6 +560,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -569,7 +575,32 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
let snapshot_content = fs::read_to_string(&snapshot_path).await?;
|
||||
assert_posix_snapshot_sections(&snapshot_content);
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
let mut saw_patch_begin = false;
|
||||
let mut patch_end = None;
|
||||
wait_for_event(&codex, |ev| match ev {
|
||||
EventMsg::PatchApplyBegin(begin) if begin.call_id == call_id => {
|
||||
saw_patch_begin = true;
|
||||
false
|
||||
}
|
||||
EventMsg::PatchApplyEnd(end) if end.call_id == call_id => {
|
||||
patch_end = Some(end.clone());
|
||||
false
|
||||
}
|
||||
EventMsg::TurnComplete(_) => true,
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
saw_patch_begin,
|
||||
"expected apply_patch to emit PatchApplyBegin"
|
||||
);
|
||||
let patch_end = patch_end.expect("expected apply_patch to emit PatchApplyEnd");
|
||||
assert!(
|
||||
patch_end.success,
|
||||
"expected apply_patch to finish successfully: stdout={:?} stderr={:?}",
|
||||
patch_end.stdout, patch_end.stderr,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
wait_for_file_contents(&target).await?,
|
||||
|
||||
@@ -65,6 +65,7 @@ async fn submit_turn_with_policies(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: core/tests/suite/compact_resume_fork.rs
|
||||
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback currently duplicates pre-turn override context updates on the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)"
|
||||
---
|
||||
Scenario: rollback currently duplicates pre-turn override context updates on the follow-up request
|
||||
|
||||
## rolled-back turn request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:turn 1 user
|
||||
03:message/assistant:turn 1 assistant
|
||||
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
06:message/user:turn 2 user
|
||||
|
||||
## follow-up request after rollback
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:turn 1 user
|
||||
03:message/assistant:turn 1 assistant
|
||||
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
06:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
07:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
08:message/user:follow-up user
|
||||
@@ -395,6 +395,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -85,6 +85,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -155,6 +156,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -235,6 +237,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -330,6 +333,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -433,6 +437,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -42,6 +42,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -358,6 +359,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -486,6 +486,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -192,6 +192,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -325,6 +326,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -407,6 +409,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -492,6 +495,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -589,6 +593,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -668,6 +673,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -748,6 +754,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -882,6 +889,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1023,6 +1031,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1187,6 +1196,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1289,6 +1299,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1411,6 +1422,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1505,6 +1517,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1590,6 +1603,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1725,6 +1739,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1897,6 +1912,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1978,6 +1994,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2070,6 +2087,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2178,6 +2196,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2317,6 +2336,7 @@ PY
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2435,6 +2455,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2535,6 +2556,7 @@ PY
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2634,6 +2656,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
// Important!
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
@@ -2743,6 +2766,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2842,6 +2866,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2981,6 +3006,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -176,6 +176,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: fixture.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -170,6 +170,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -278,6 +279,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -418,6 +420,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -516,6 +519,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -607,6 +611,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -706,6 +711,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -816,6 +822,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -917,6 +924,7 @@ await codex.emitImage(out);
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1036,6 +1044,7 @@ console.log(out.type);
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1128,6 +1137,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1203,6 +1213,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1283,6 +1294,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1405,6 +1417,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: model_slug.to_string(),
|
||||
effort: None,
|
||||
@@ -1479,6 +1492,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -156,6 +156,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -65,7 +65,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t
|
||||
For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../protocol/src/protocol.rs). Some example payload types:
|
||||
|
||||
- `Op`
|
||||
- `Op::UserTurn` – Any input from the user to kick off a `Turn`
|
||||
- `Op::UserTurn` – Any input from the user to kick off a `Turn`, including full per-turn context such as cwd, model, sandbox, approval policy, and optional `approvals_reviewer`
|
||||
- `Op::UserInput` – Legacy form of user input
|
||||
- `Op::Interrupt` – Interrupts a running turn
|
||||
- `Op::ExecApproval` – Approve or deny code execution
|
||||
|
||||
@@ -988,6 +988,7 @@ impl EventProcessorWithHumanOutput {
|
||||
|
||||
fn hook_event_name(event_name: HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
HookEventName::PreToolUse => "PreToolUse",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEventName::Stop => "Stop",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user