diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f23a999d64..bbd2df27cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 9db72b1d21..c203e2b742 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -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: diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 4a3b35bd1c..f7f650b8c1 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -499,7 +499,7 @@ jobs: fi - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false @@ -693,7 +693,7 @@ jobs: steps: - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f + uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 with: identifier: OpenAI.Codex version: ${{ needs.release.outputs.version }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 5d13042fbe..c5026fe8c5 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -23,7 +23,7 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false diff --git a/.github/workflows/shell-tool-mcp-ci.yml b/.github/workflows/shell-tool-mcp-ci.yml index 739cc6e409..62a61efb93 100644 --- a/.github/workflows/shell-tool-mcp-ci.yml +++ b/.github/workflows/shell-tool-mcp-ci.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index a79f6221fa..71d1725caf 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -428,7 +428,7 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 778dee3ea7..9ead5c6e17 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1448,6 +1448,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-sandboxing", "codex-shell-command", "codex-state", "codex-utils-absolute-path", @@ -1488,7 +1489,6 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-core", - "codex-features", "codex-feedback", "codex-protocol", "futures", @@ -1678,6 +1678,7 @@ dependencies = [ "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-sandboxing", "codex-state", "codex-stdio-to-uds", "codex-terminal-detection", @@ -1885,6 +1886,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-sandboxing", "codex-secrets", "codex-shell-command", "codex-shell-escalation", @@ -1996,9 +1998,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", - "codex-utils-elapsed", "codex-utils-oss", - "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", @@ -2006,10 +2006,8 @@ dependencies = [ "owo-colors", "predicates", "pretty_assertions", - "rmcp", "serde", "serde_json", - "shlex", "supports-color 3.0.2", "tempfile", "tokio", @@ -2462,6 +2460,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" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 524b61e3b8..e0c7633733 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index 5a3a1aa73f..a0b98c0fec 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -16,7 +16,6 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-core = { workspace = true } -codex-features = { workspace = true } codex-feedback = { workspace = true } codex-protocol = { workspace = true } futures = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index acf9c77a10..115e9808f0 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -35,19 +35,14 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_core::AuthManager; -use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; -use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; -use codex_features::Feature; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use serde::de::DeserializeOwned; @@ -73,7 +68,6 @@ pub type RequestResult = std::result::Result; pub enum AppServerEvent { Lagged { skipped: usize }, ServerNotification(ServerNotification), - LegacyNotification(JSONRPCNotification), ServerRequest(ServerRequest), Disconnected { message: String }, } @@ -85,9 +79,6 @@ impl From for AppServerEvent { InProcessServerEvent::ServerNotification(notification) => { Self::ServerNotification(notification) } - InProcessServerEvent::LegacyNotification(notification) => { - Self::LegacyNotification(notification) - } InProcessServerEvent::ServerRequest(request) => Self::ServerRequest(request), } } @@ -97,19 +88,12 @@ fn event_requires_delivery(event: &InProcessServerEvent) -> bool { // These terminal events drive surface shutdown/completion state. Dropping // them under backpressure can leave exec/TUI waiting forever even though // the underlying turn has already ended. - match event { + matches!( + event, InProcessServerEvent::ServerNotification( codex_app_server_protocol::ServerNotification::TurnCompleted(_), - ) => true, - InProcessServerEvent::LegacyNotification(notification) => matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ), - _ => false, - } + ) + ) } /// Layered error for [`InProcessAppServerClient::request_typed`]. @@ -159,16 +143,6 @@ impl Error for TypedRequestError { } } -#[derive(Clone)] -struct SharedCoreManagers { - // Temporary bootstrap escape hatch for embedders that still need direct - // core handles during the in-process app-server migration. Once TUI/exec - // stop depending on direct manager access, remove this wrapper and keep - // manager ownership entirely inside the app-server runtime. - auth_manager: Arc, - thread_manager: Arc, -} - #[derive(Clone)] pub struct InProcessClientStartArgs { /// Resolved argv0 dispatch paths used by command execution internals. @@ -202,30 +176,6 @@ pub struct InProcessClientStartArgs { } impl InProcessClientStartArgs { - fn shared_core_managers(&self) -> SharedCoreManagers { - let auth_manager = AuthManager::shared( - self.config.codex_home.clone(), - self.enable_codex_api_key_env, - self.config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - self.config.as_ref(), - auth_manager.clone(), - self.session_source.clone(), - CollaborationModesConfig { - default_mode_request_user_input: self - .config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); - - SharedCoreManagers { - auth_manager, - thread_manager, - } - } - /// Builds initialize params from caller-provided metadata. pub fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { @@ -247,7 +197,7 @@ impl InProcessClientStartArgs { } } - fn into_runtime_start_args(self, shared_core: &SharedCoreManagers) -> InProcessStartArgs { + fn into_runtime_start_args(self) -> InProcessStartArgs { let initialize = self.initialize_params(); InProcessStartArgs { arg0_paths: self.arg0_paths, @@ -255,8 +205,6 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, cloud_requirements: self.cloud_requirements, - auth_manager: Some(shared_core.auth_manager.clone()), - thread_manager: Some(shared_core.thread_manager.clone()), feedback: self.feedback, config_warnings: self.config_warnings, session_source: self.session_source, @@ -310,8 +258,6 @@ pub struct InProcessAppServerClient { command_tx: mpsc::Sender, event_rx: mpsc::Receiver, worker_handle: tokio::task::JoinHandle<()>, - auth_manager: Arc, - thread_manager: Arc, } #[derive(Clone)] @@ -338,9 +284,8 @@ impl InProcessAppServerClient { /// with overload error instead of being silently dropped. pub async fn start(args: InProcessClientStartArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); - let shared_core = args.shared_core_managers(); let mut handle = - codex_app_server::in_process::start(args.into_runtime_start_args(&shared_core)).await?; + codex_app_server::in_process::start(args.into_runtime_start_args()).await?; let request_sender = handle.sender(); let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -401,6 +346,25 @@ impl InProcessAppServerClient { let Some(event) = event else { break; }; + if let InProcessServerEvent::ServerRequest( + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } + ) = &event + { + let send_result = request_sender.fail_server_request( + request_id.clone(), + JSONRPCErrorError { + code: -32000, + message: "chatgpt auth token refresh is not supported for in-process app-server clients".to_string(), + data: None, + }, + ); + if let Err(err) = send_result { + warn!( + "failed to reject unsupported chatgpt auth token refresh request: {err}" + ); + } + continue; + } if skipped_events > 0 { if event_requires_delivery(&event) { @@ -491,21 +455,9 @@ impl InProcessAppServerClient { command_tx, event_rx, worker_handle, - auth_manager: shared_core.auth_manager, - thread_manager: shared_core.thread_manager, }) } - /// Temporary bootstrap escape hatch for embedders migrating toward RPC-only usage. - pub fn auth_manager(&self) -> Arc { - self.auth_manager.clone() - } - - /// Temporary bootstrap escape hatch for embedders migrating toward RPC-only usage. - pub fn thread_manager(&self) -> Arc { - self.thread_manager.clone() - } - pub fn request_handle(&self) -> InProcessAppServerRequestHandle { InProcessAppServerRequestHandle { command_tx: self.command_tx.clone(), @@ -664,8 +616,6 @@ impl InProcessAppServerClient { command_tx, event_rx, worker_handle, - auth_manager: _, - thread_manager: _, } = self; let mut worker_handle = worker_handle; // Drop the caller-facing receiver before asking the worker to shut @@ -857,8 +807,6 @@ mod tests { use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; - use codex_core::AuthManager; - use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; use futures::SinkExt; use futures::StreamExt; @@ -1052,7 +1000,7 @@ mod tests { } #[tokio::test] - async fn shared_thread_manager_tracks_threads_started_via_app_server() { + async fn threads_started_via_app_server_are_visible_through_typed_requests() { let client = start_test_client(SessionSource::Cli).await; let response: ThreadStartResponse = client @@ -1065,17 +1013,19 @@ mod tests { }) .await .expect("thread/start should succeed"); - let created_thread_id = codex_protocol::ThreadId::from_string(&response.thread.id) - .expect("thread id should parse"); - timeout( - Duration::from_secs(2), - client.thread_manager().get_thread(created_thread_id), - ) - .await - .expect("timed out waiting for retained thread manager to observe started thread") - .expect("started thread should be visible through the shared thread manager"); - let thread_ids = client.thread_manager().list_thread_ids().await; - assert!(thread_ids.contains(&created_thread_id)); + let read = client + .request_typed::( + ClientRequest::ThreadRead { + request_id: RequestId::Integer(4), + params: codex_app_server_protocol::ThreadReadParams { + thread_id: response.thread.id.clone(), + include_turns: false, + }, + }, + ) + .await + .expect("thread/read should return the newly started thread"); + assert_eq!(read.thread.id, response.thread.id); client.shutdown().await.expect("shutdown should complete"); } @@ -1472,22 +1422,6 @@ mod tests { let (command_tx, _command_rx) = mpsc::channel(1); let (event_tx, event_rx) = mpsc::channel(1); let worker_handle = tokio::spawn(async {}); - let config = build_test_config().await; - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - false, - config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - &config, - auth_manager.clone(), - SessionSource::Exec, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); event_tx .send(InProcessServerEvent::Lagged { skipped: 3 }) .await @@ -1498,8 +1432,6 @@ mod tests { command_tx, event_rx, worker_handle, - auth_manager, - thread_manager, }; let event = timeout(Duration::from_secs(2), client.next_event()) @@ -1530,37 +1462,38 @@ mod tests { ) ) )); - assert!(event_requires_delivery( - &InProcessServerEvent::LegacyNotification( - codex_app_server_protocol::JSONRPCNotification { - method: "codex/event/turn_aborted".to_string(), - params: None, - } - ) - )); assert!(!event_requires_delivery(&InProcessServerEvent::Lagged { skipped: 1 })); } #[tokio::test] - async fn accessors_expose_retained_shared_managers() { - let client = start_test_client(SessionSource::Cli).await; + async fn runtime_start_args_leave_manager_bootstrap_to_app_server() { + let config = Arc::new(build_test_config().await); - assert!( - Arc::ptr_eq(&client.auth_manager(), &client.auth_manager()), - "auth_manager accessor should clone the retained shared manager" - ); - assert!( - Arc::ptr_eq(&client.thread_manager(), &client.thread_manager()), - "thread_manager accessor should clone the retained shared manager" - ); + let runtime_args = InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: config.clone(), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + } + .into_runtime_start_args(); - client.shutdown().await.expect("shutdown should complete"); + assert_eq!(runtime_args.config, config); } #[tokio::test] - async fn shutdown_completes_promptly_with_retained_shared_managers() { + async fn shutdown_completes_promptly_without_retained_managers() { let client = start_test_client(SessionSource::Cli).await; timeout(Duration::from_secs(1), client.shutdown()) diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index b747595204..b9179716fe 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -272,18 +272,19 @@ impl RemoteAppServerClient { } } Ok(JSONRPCMessage::Notification(notification)) => { - let event = app_server_event_from_notification(notification); - if let Err(err) = deliver_event( - &event_tx, - &mut skipped_events, - event, - &mut stream, - ) - .await - { - warn!(%err, "failed to deliver remote app-server event"); - break; - } + if let Some(event) = + app_server_event_from_notification(notification) + && let Err(err) = deliver_event( + &event_tx, + &mut skipped_events, + event, + &mut stream, + ) + .await + { + warn!(%err, "failed to deliver remote app-server event"); + break; + } } Ok(JSONRPCMessage::Request(request)) => { let request_id = request.id.clone(); @@ -673,7 +674,9 @@ async fn initialize_remote_connection( ))); } JSONRPCMessage::Notification(notification) => { - pending_events.push(app_server_event_from_notification(notification)); + if let Some(event) = app_server_event_from_notification(notification) { + pending_events.push(event); + } } JSONRPCMessage::Request(request) => { let request_id = request.id.clone(); @@ -756,10 +759,10 @@ async fn initialize_remote_connection( Ok(pending_events) } -fn app_server_event_from_notification(notification: JSONRPCNotification) -> AppServerEvent { - match ServerNotification::try_from(notification.clone()) { - Ok(notification) => AppServerEvent::ServerNotification(notification), - Err(_) => AppServerEvent::LegacyNotification(notification), +fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option { + match ServerNotification::try_from(notification) { + Ok(notification) => Some(AppServerEvent::ServerNotification(notification)), + Err(_) => None, } } @@ -852,13 +855,6 @@ async fn reject_if_server_request_dropped( fn event_requires_delivery(event: &AppServerEvent) -> bool { match event { AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(_)) => true, - AppServerEvent::LegacyNotification(notification) => matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ), AppServerEvent::Disconnected { .. } => true, AppServerEvent::Lagged { .. } | AppServerEvent::ServerNotification(_) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 11dfe29769..48fa56d687 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -74,6 +74,8 @@ pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, 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 { + self.current_turn + .as_ref() + .filter(|turn| turn.opened_explicitly) + .map(|turn| turn.id.clone()) + } + + pub fn active_turn_start_index(&self) -> Option { + 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 { diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index c6be85984d..f9e069703e 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -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 } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ace7f2bd43..a536e5c68b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 34640a50cf..15484d3d5f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 947a49ff2f..c4c2799ca8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -12,7 +12,6 @@ use crate::models::supported_models; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; -use crate::outgoing_message::OutgoingNotification; use crate::outgoing_message::RequestContext; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::thread_status::ThreadWatchManager; @@ -183,6 +182,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 +4039,7 @@ impl CodexMessageProcessor { } = match self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path.clone(), persist_extended_history, @@ -6508,7 +6508,7 @@ impl CodexMessageProcessor { } = self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path, /*persist_extended_history*/ false, @@ -6830,43 +6830,9 @@ impl CodexMessageProcessor { } }; - // For now, we send a notification for every event, - // Legacy `codex/event/*` notifications are still - // produced here because the in-process app-server lane - // (`codex exec` and other in-process consumers) still - // depends on them. External transports now drop - // `OutgoingMessage::Notification` in `transport.rs`, - // so stdio/websocket clients only observe the typed - // `ServerNotification` translations emitted below. - // - // TODO: remove this raw legacy-notification emission - // entirely once the remaining in-process consumers are - // migrated off `codex/event/*`. - let event_formatted = match &event.msg { - EventMsg::TurnStarted(_) => "task_started", - EventMsg::TurnComplete(_) => "task_complete", - _ => &event.msg.to_string(), - }; - let request_event_name = format!("codex/event/{event_formatted}"); - tracing::trace!( - conversation_id = %conversation_id, - "app-server event: {request_event_name}" - ); - let mut params = match serde_json::to_value(event.clone()) { - Ok(serde_json::Value::Object(map)) => map, - Ok(_) => { - error!("event did not serialize to an object"); - continue; - } - Err(err) => { - error!("failed to serialize event: {err}"); - continue; - } - }; - params.insert( - "conversationId".to_string(), - conversation_id.to_string().into(), - ); + // Track the event before emitting any typed + // translations so thread-local state such as raw event + // opt-in stays synchronized with the conversation. let raw_events_enabled = { let mut thread_state = thread_state.lock().await; thread_state.track_current_turn_event(&event.msg); @@ -6879,18 +6845,6 @@ impl CodexMessageProcessor { continue; } - if !subscribed_connection_ids.is_empty() { - outgoing_for_task - .send_notification_to_connections( - &subscribed_connection_ids, - OutgoingNotification { - method: request_event_name, - params: Some(params.into()), - }, - ) - .await; - } - let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing_for_task.clone(), subscribed_connection_ids, diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index e1a9cb3def..de508b953c 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -23,8 +23,8 @@ use codex_core::config::StartedNetworkProxy; use codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; use codex_core::exec::ExecExpiration; use codex_core::exec::IO_DRAIN_TIMEOUT_MS; -use codex_core::exec::SandboxType; use codex_core::sandboxing::ExecRequest; +use codex_sandboxing::SandboxType; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::ProcessHandle; use codex_utils_pty::SpawnedProcess; diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4288d15393..0826f33c67 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -68,14 +68,11 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_core::AuthManager; -use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -98,16 +95,6 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b matches!(notification, ServerNotification::TurnCompleted(_)) } -fn legacy_notification_requires_delivery(notification: &JSONRPCNotification) -> bool { - matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ) -} - /// Input needed to start an in-process app-server runtime. /// /// These fields mirror the pieces of ambient process state that stdio and @@ -124,10 +111,6 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, - /// Optional prebuilt auth manager reused by an embedding caller. - pub auth_manager: Option>, - /// Optional prebuilt thread manager reused by an embedding caller. - pub thread_manager: Option>, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// Startup warnings emitted after initialize succeeds. @@ -144,11 +127,6 @@ pub struct InProcessStartArgs { /// Event emitted from the app-server to the in-process client. /// -/// The stream carries three event families because CLI surfaces are mid-migration -/// from the legacy `codex_protocol::Event` model to the typed app-server -/// notification model. Once all surfaces consume only [`ServerNotification`], -/// [`LegacyNotification`](Self::LegacyNotification) can be removed. -/// /// [`Lagged`](Self::Lagged) is a transport health marker, not an application /// event — it signals that the consumer fell behind and some events were dropped. #[derive(Debug, Clone)] @@ -157,8 +135,6 @@ pub enum InProcessServerEvent { ServerRequest(ServerRequest), /// App-server notification directed to the embedded client. ServerNotification(ServerNotification), - /// Legacy JSON-RPC notification from core event bridge. - LegacyNotification(JSONRPCNotification), /// Indicates one or more events were dropped due to backpressure. Lagged { skipped: usize }, } @@ -390,7 +366,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { Arc::clone(&outbound_initialized), Arc::clone(&outbound_experimental_api_enabled), Arc::clone(&outbound_opted_out_notification_methods), - /*allow_legacy_notifications*/ true, /*disconnect_sender*/ None, ), ); @@ -410,8 +385,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { cli_overrides: args.cli_overrides, loader_overrides: args.loader_overrides, cloud_requirements: args.cloud_requirements, - auth_manager: args.auth_manager, - thread_manager: args.thread_manager, feedback: args.feedback, log_db: None, config_warnings: args.config_warnings, @@ -655,32 +628,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } } } - OutgoingMessage::Notification(notification) => { - let notification = JSONRPCNotification { - method: notification.method, - params: notification.params, - }; - if legacy_notification_requires_delivery(¬ification) { - if event_tx - .send(InProcessServerEvent::LegacyNotification(notification)) - .await - .is_err() - { - break; - } - } else if let Err(send_error) = - event_tx.try_send(InProcessServerEvent::LegacyNotification(notification)) - { - match send_error { - mpsc::error::TrySendError::Full(_) => { - warn!("dropping in-process legacy notification (queue full)"); - } - mpsc::error::TrySendError::Closed(_) => { - break; - } - } - } - } } } } @@ -759,8 +706,6 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), - auth_manager: None, - thread_manager: None, feedback: CodexFeedback::new(), config_warnings: Vec::new(), session_source, @@ -858,7 +803,7 @@ mod tests { } #[test] - fn guaranteed_delivery_helpers_cover_terminal_notifications() { + fn guaranteed_delivery_helpers_cover_terminal_server_notifications() { assert!(server_notification_requires_delivery( &ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: "thread-1".to_string(), @@ -870,30 +815,5 @@ mod tests { }, }) )); - - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/task_complete".to_string(), - params: None, - } - )); - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/turn_aborted".to_string(), - params: None, - } - )); - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/shutdown_complete".to_string(), - params: None, - } - )); - assert!(!legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/item_started".to_string(), - params: None, - } - )); } } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a4994d34b8..f044ddd2e9 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -104,8 +104,6 @@ enum OutboundControlEvent { Opened { connection_id: ConnectionId, writer: mpsc::Sender, - // Allow codex/event/* notifications to be emitted. - allow_legacy_notifications: bool, disconnect_sender: Option, initialized: Arc, experimental_api_enabled: Arc, @@ -562,7 +560,6 @@ pub async fn run_main_with_transport( OutboundControlEvent::Opened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, initialized, experimental_api_enabled, @@ -575,7 +572,6 @@ pub async fn run_main_with_transport( initialized, experimental_api_enabled, opted_out_notification_methods, - allow_legacy_notifications, disconnect_sender, ), ); @@ -618,8 +614,6 @@ pub async fn run_main_with_transport( cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), - auth_manager: None, - thread_manager: None, feedback: feedback.clone(), log_db, config_warnings, @@ -675,7 +669,6 @@ pub async fn run_main_with_transport( TransportEvent::ConnectionOpened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, } => { let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -687,7 +680,6 @@ pub async fn run_main_with_transport( .send(OutboundControlEvent::Opened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, initialized: Arc::clone(&outbound_initialized), experimental_api_enabled: Arc::clone( diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 287fe79755..4f53bfc1a4 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -172,8 +172,6 @@ pub(crate) struct MessageProcessorArgs { pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, - pub(crate) auth_manager: Option>, - pub(crate) thread_manager: Option>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, pub(crate) config_warnings: Vec, @@ -192,36 +190,27 @@ impl MessageProcessor { cli_overrides, loader_overrides, cloud_requirements, - auth_manager, - thread_manager, feedback, log_db, config_warnings, session_source, enable_codex_api_key_env, } = args; - let (auth_manager, thread_manager) = match (auth_manager, thread_manager) { - (Some(auth_manager), Some(thread_manager)) => (auth_manager, thread_manager), - (None, None) => { - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - config.as_ref(), - auth_manager.clone(), - session_source, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); - (auth_manager, thread_manager) - } - _ => panic!("MessageProcessorArgs must provide both auth_manager and thread_manager"), - }; + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + enable_codex_api_key_env, + config.cli_auth_credentials_store_mode, + ); + let thread_manager = Arc::new(ThreadManager::new( + config.as_ref(), + auth_manager.clone(), + session_source, + CollaborationModesConfig { + default_mode_request_user_input: config + .features + .enabled(Feature::DefaultModeRequestUserInput), + }, + )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 58499745bd..51a89280a7 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -239,8 +239,6 @@ fn build_test_processor( cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), - auth_manager: None, - thread_manager: None, feedback: CodexFeedback::new(), log_db: None, config_warnings: Vec::new(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 67761525b3..5802eb44c9 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -527,38 +527,6 @@ impl OutgoingMessageSender { } } - pub(crate) async fn send_notification_to_connections( - &self, - connection_ids: &[ConnectionId], - notification: OutgoingNotification, - ) { - let outgoing_message = OutgoingMessage::Notification(notification); - if connection_ids.is_empty() { - if let Err(err) = self - .sender - .send(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) - .await - { - warn!("failed to send notification to client: {err:?}"); - } - return; - } - for connection_id in connection_ids { - if let Err(err) = self - .sender - .send(OutgoingEnvelope::ToConnection { - connection_id: *connection_id, - message: outgoing_message.clone(), - }) - .await - { - warn!("failed to send notification to client: {err:?}"); - } - } - } - pub(crate) async fn send_error( &self, request_id: ConnectionRequestId, @@ -616,7 +584,6 @@ impl OutgoingMessageSender { #[serde(untagged)] pub(crate) enum OutgoingMessage { Request(ServerRequest), - Notification(OutgoingNotification), /// AppServerNotification is specific to the case where this is run as an /// "app server" as opposed to an MCP server. AppServerNotification(ServerNotification), @@ -624,13 +591,6 @@ pub(crate) enum OutgoingMessage { Error(OutgoingError), } -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingNotification { - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, -} - #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingResponse { pub id: RequestId, diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 3e24d831ae..5fd9fa42cf 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -188,7 +188,6 @@ pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, writer: mpsc::Sender, - allow_legacy_notifications: bool, disconnect_sender: Option, }, ConnectionClosed { @@ -226,7 +225,6 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) experimental_api_enabled: Arc, pub(crate) opted_out_notification_methods: Arc>>, - pub(crate) allow_legacy_notifications: bool, pub(crate) writer: mpsc::Sender, disconnect_sender: Option, } @@ -237,14 +235,12 @@ impl OutboundConnectionState { initialized: Arc, experimental_api_enabled: Arc, opted_out_notification_methods: Arc>>, - allow_legacy_notifications: bool, disconnect_sender: Option, ) -> Self { Self { initialized, experimental_api_enabled, opted_out_notification_methods, - allow_legacy_notifications, writer, disconnect_sender, } @@ -272,7 +268,6 @@ pub(crate) async fn start_stdio_connection( .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, - allow_legacy_notifications: false, disconnect_sender: None, }) .await @@ -376,7 +371,6 @@ async fn run_websocket_connection( .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, - allow_legacy_notifications: false, disconnect_sender: Some(disconnect_token.clone()), }) .await @@ -584,16 +578,6 @@ fn should_skip_notification_for_connection( connection_state: &OutboundConnectionState, message: &OutgoingMessage, ) -> bool { - if !connection_state.allow_legacy_notifications - && matches!(message, OutgoingMessage::Notification(_)) - { - // Raw legacy `codex/event/*` notifications are still emitted upstream - // for in-process compatibility, but they are no longer part of the - // external app-server contract. Keep dropping them here until the - // producer path can be deleted entirely. - return true; - } - let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read() else { warn!("failed to read outbound opted-out notifications"); @@ -604,9 +588,6 @@ fn should_skip_notification_for_connection( let method = notification.to_string(); opted_out_notification_methods.contains(method.as_str()) } - OutgoingMessage::Notification(notification) => { - opted_out_notification_methods.contains(notification.method.as_str()) - } _ => false, } } @@ -719,6 +700,8 @@ mod tests { use super::*; use crate::error_code::OVERLOADED_ERROR_CODE; use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::ServerNotification; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -921,11 +904,13 @@ mod tests { .expect("transport queue should accept first message"); writer_tx - .send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "queued".to_string(), - params: None, - }, + .send(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }), )) .await .expect("writer queue should accept first message"); @@ -950,7 +935,16 @@ mod tests { .await .expect("writer queue should still contain original message"); let queued_json = serde_json::to_value(queued_outgoing).expect("serialize queued message"); - assert_eq!(queued_json, json!({ "method": "queued" })); + assert_eq!( + queued_json, + json!({ + "method": "configWarning", + "params": { + "summary": "queued", + "details": null, + }, + }) + ); } #[tokio::test] @@ -958,9 +952,8 @@ mod tests { let connection_id = ConnectionId(7); let (writer_tx, mut writer_rx) = mpsc::channel(1); let initialized = Arc::new(AtomicBool::new(true)); - let opted_out_notification_methods = Arc::new(RwLock::new(HashSet::from([ - "codex/event/task_started".to_string(), - ]))); + let opted_out_notification_methods = + Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))); let mut connections = HashMap::new(); connections.insert( @@ -970,7 +963,6 @@ mod tests { initialized, Arc::new(AtomicBool::new(true)), opted_out_notification_methods, - false, None, ), ); @@ -979,12 +971,14 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, }, - ), + )), }, ) .await; @@ -995,89 +989,6 @@ mod tests { ); } - #[tokio::test] - async fn to_connection_legacy_notifications_are_dropped_for_external_clients() { - let connection_id = ConnectionId(10); - let (writer_tx, mut writer_rx) = mpsc::channel(1); - - let mut connections = HashMap::new(); - connections.insert( - connection_id, - OutboundConnectionState::new( - writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - false, - None, - ), - ); - - route_outgoing_envelope( - &mut connections, - OutgoingEnvelope::ToConnection { - connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, - }, - ), - }, - ) - .await; - - assert!( - writer_rx.try_recv().is_err(), - "legacy notifications should not reach external clients" - ); - } - - #[tokio::test] - async fn to_connection_legacy_notifications_are_preserved_for_in_process_clients() { - let connection_id = ConnectionId(11); - let (writer_tx, mut writer_rx) = mpsc::channel(1); - - let mut connections = HashMap::new(); - connections.insert( - connection_id, - OutboundConnectionState::new( - writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - true, - None, - ), - ); - - route_outgoing_envelope( - &mut connections, - OutgoingEnvelope::ToConnection { - connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, - }, - ), - }, - ) - .await; - - let message = writer_rx - .recv() - .await - .expect("legacy notification should reach in-process clients"); - assert!(matches!( - message, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "codex/event/task_started" - )); - } - #[tokio::test] async fn command_execution_request_approval_strips_experimental_fields_without_capability() { let connection_id = ConnectionId(8); @@ -1091,7 +1002,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(false)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1158,7 +1068,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1246,7 +1155,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, Some(fast_disconnect_token.clone()), ), ); @@ -1257,25 +1165,30 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, Some(slow_disconnect_token.clone()), ), ); - let queued_message = - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/already-buffered".to_string(), - params: None, - }); + let queued_message = OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "already-buffered".to_string(), + details: None, + path: None, + range: None, + }), + ); slow_writer_tx .try_send(queued_message) .expect("channel should have room"); - let broadcast_message = - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/test".to_string(), - params: None, - }); + let broadcast_message = OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }), + ); timeout( Duration::from_millis(100), route_outgoing_envelope( @@ -1286,24 +1199,28 @@ mod tests { ), ) .await - .expect("broadcast should return even when legacy notifications are dropped"); - assert!(connections.contains_key(&slow_connection_id)); - assert!(!slow_disconnect_token.is_cancelled()); + .expect("broadcast should return even when one connection is slow"); + assert!(!connections.contains_key(&slow_connection_id)); + assert!(slow_disconnect_token.is_cancelled()); assert!(!fast_disconnect_token.is_cancelled()); - assert!( - fast_writer_rx.try_recv().is_err(), - "broadcast legacy notification should be dropped for fast connections" - ); + let fast_message = fast_writer_rx + .try_recv() + .expect("fast connection should receive the broadcast notification"); + assert!(matches!( + fast_message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "test" + )); let slow_message = slow_writer_rx .try_recv() .expect("slow connection should retain its original buffered message"); assert!(matches!( slow_message, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "codex/event/already-buffered" + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "already-buffered" )); } @@ -1312,11 +1229,13 @@ mod tests { let connection_id = ConnectionId(3); let (writer_tx, mut writer_rx) = mpsc::channel(1); writer_tx - .send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "queued".to_string(), - params: None, - }, + .send(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }), )) .await .expect("channel should accept the first queued message"); @@ -1329,7 +1248,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1339,11 +1257,13 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "second".to_string(), - params: None, - }, + message: OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "second".to_string(), + details: None, + path: None, + range: None, + }), ), }, ) @@ -1356,20 +1276,23 @@ mod tests { .expect("first queued message should exist"); timeout(Duration::from_millis(100), route_task) .await - .expect("routing should finish immediately when legacy notifications are dropped") + .expect("routing should finish after the first queued message is drained") .expect("routing task should succeed"); assert!(matches!( first, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "queued" + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "queued" )); + let second = writer_rx + .try_recv() + .expect("second notification should be delivered once the queue has room"); assert!(matches!( - writer_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - | Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) + second, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "second" )); } } diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index f0216f6bae..c5c76bf8e9 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -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>; diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 1b40fadc67..a81c87bd4e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -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, .. } => { diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 37ca5a3903..1a73444bda 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -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"]"# +} diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d8dbaaa30d..e210783a35 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -309,14 +309,16 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { - 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 { diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c2fd1300c6..7e703efe10 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -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 } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c65b6dcad5..03226cced4 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -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; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 6386c8d424..9c20345170 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -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 } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 282e29c48a..7b2910db1d 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -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, +} + fn default_agent_nickname_list() -> Vec<&'static str> { AGENT_NAMES .lines() @@ -456,21 +469,28 @@ impl AgentControl { agent_id: ThreadId, items: Vec, ) -> CodexResult { + 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. @@ -494,15 +514,22 @@ impl AgentControl { agent_id: ThreadId, communication: InterAgentCommunication, ) -> CodexResult { + 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. @@ -680,6 +707,70 @@ impl AgentControl { .join("\n") } + pub(crate) async fn list_agents( + &self, + current_session_source: &SessionSource, + path_prefix: Option<&str>, + ) -> CodexResult> { + 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 @@ -800,6 +891,7 @@ impl AgentControl { agent_path, agent_nickname, agent_role, + last_task_message: None, }; Ok((session_source, agent_metadata)) } @@ -963,6 +1055,95 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option, 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 { + 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 { + let response_item: ResponseItem = item.clone().into(); + last_task_message_from_item(&response_item) +} + +fn last_task_message_from_item(item: &ResponseItem) -> Option { + 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::(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::>() + .join("\n") +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 2cf7398f7d..38fbcbed51 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -29,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( @@ -1019,7 +1021,14 @@ async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() ) .await; - timeout(Duration::from_secs(2), async { + 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 @@ -1030,10 +1039,7 @@ async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() && matches!( op, Op::InterAgentCommunication { communication } - if communication.author == tester_path - && communication.recipient == worker_path - && communication.other_recipients.is_empty() - && communication.content == "done" + if communication == expected ) }); if delivered { @@ -1044,39 +1050,6 @@ async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() }) .await .expect("completion watcher should send inter-agent communication"); - - let worker_thread = harness - .manager - .get_thread(worker_thread_id) - .await - .expect("worker thread should exist"); - let expected_message = InterAgentCommunication::new( - tester_path.clone(), - worker_path.clone(), - Vec::new(), - "done".to_string(), - ); - timeout(Duration::from_secs(2), async { - loop { - let history_items = worker_thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - if history_contains_assistant_inter_agent_communication( - &history_items, - &expected_message, - ) && !has_subagent_notification(&history_items) - { - break; - } - sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("worker should record assistant inter-agent message"); } #[tokio::test] diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index af545e8c97..f78c8d08bc 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -38,6 +38,7 @@ pub(crate) struct AgentMetadata { pub(crate) agent_path: Option, pub(crate) agent_nickname: Option, pub(crate) agent_role: Option, + pub(crate) last_task_message: Option, } 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 { + 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; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5e428362f2..d305a1e108 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3956,6 +3956,17 @@ impl Session { } } + pub(crate) async fn pending_input_snapshot(&self) -> Vec { + 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) { 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 { + 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() } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7786b30873..7d3af80249 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5639,8 +5639,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"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cbbd023905..dc04d9bb14 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -477,7 +477,7 @@ pub struct Config { pub file_opener: UriBasedFileOpener, /// Path to the `codex-linux-sandbox` executable. This must be set if - /// [`crate::exec::SandboxType::LinuxSeccomp`] is used. Note that this + /// [`codex_sandboxing::SandboxType::LinuxSeccomp`] is used. Note that this /// cannot be set in the config file: it must be set in code via /// [`ConfigOverrides`]. /// diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 80d60619dd..b84180d160 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -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()) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9be0518fa0..7f4664858a 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -24,20 +24,23 @@ use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecOutputStream; use crate::protocol::SandboxPolicy; -use crate::sandboxing::CommandSpec; +use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; -use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxPermissions; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; -use crate::tools::sandboxing::SandboxablePreference; use codex_network_proxy::NetworkProxy; #[cfg(any(target_os = "windows", test))] use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -178,31 +181,6 @@ impl ExecCapturePolicy { } } -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum SandboxType { - None, - - /// Only available on macOS. - MacosSeatbelt, - - /// Only available on Linux. - LinuxSeccomp, - - /// Only available on Windows. - WindowsRestrictedToken, -} - -impl SandboxType { - pub(crate) fn as_metric_tag(self) -> &'static str { - match self { - SandboxType::None => "none", - SandboxType::MacosSeatbelt => "seatbelt", - SandboxType::LinuxSeccomp => "seccomp", - SandboxType::WindowsRestrictedToken => "windows_sandbox", - } - } -} - #[derive(Clone)] pub struct StdoutStream { pub sub_id: String, @@ -279,22 +257,23 @@ pub fn build_exec_request( )) })?; - let spec = CommandSpec { + let manager = SandboxManager::new(); + let command = SandboxCommand { program: program.clone(), args: args.to_vec(), cwd, env, + additional_permissions: None, + }; + let options = ExecOptions { expiration, capture_policy, sandbox_permissions, - additional_permissions: None, justification, }; - - let manager = SandboxManager::new(); let exec_req = manager - .transform(crate::sandboxing::SandboxTransformRequest { - spec, + .transform(SandboxTransformRequest { + command, policy: sandbox_policy, file_system_policy: file_system_sandbox_policy, network_policy: network_sandbox_policy, @@ -309,6 +288,7 @@ pub fn build_exec_request( windows_sandbox_level, windows_sandbox_private_desktop, }) + .map(|request| ExecRequest::from_sandbox_exec_request(request, options)) .map_err(CodexErr::from)?; Ok(exec_req) } @@ -620,7 +600,7 @@ fn finalize_exec_result( pub(crate) mod errors { use super::CodexErr; - use crate::sandboxing::SandboxTransformError; + use codex_sandboxing::SandboxTransformError; impl From for CodexErr { fn from(err: SandboxTransformError) -> Self { diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index fc312ec88e..6f452d6e8a 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::time::Duration; @@ -542,7 +543,7 @@ fn windows_elevated_sandbox_allows_restricted_read_only_policies() { #[test] fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() { - let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None); + let expected = codex_sandboxing::get_platform_sandbox(false).unwrap_or(SandboxType::None); assert_eq!( select_process_exec_tool_sandbox_type( diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 19b3f7c6af..c1dc4ba542 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -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, - 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 { - 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 = 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, - command_cwd: &Path, - sandbox_policy_cwd: &Path, - use_legacy_landlock: bool, - allow_network_for_proxy: bool, -) -> Vec { - 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 = 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; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 29436a0d7f..1c92080502 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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")] @@ -180,7 +180,6 @@ pub use exec_policy::check_execpolicy_for_warnings; pub use exec_policy::format_exec_policy_error_with_source; pub use exec_policy::load_exec_policy; pub use file_watcher::FileWatcherEvent; -pub use safety::get_platform_sandbox; pub use tools::spec::parse_tool_input_schema; pub use turn_metadata::build_turn_metadata_header; pub mod compact; diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index 490bf42b97..fee84974fe 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -46,8 +46,8 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec Option { - if cfg!(target_os = "macos") { - Some(SandboxType::MacosSeatbelt) - } else if cfg!(target_os = "linux") { - Some(SandboxType::LinuxSeccomp) - } else if cfg!(target_os = "windows") { - if windows_sandbox_enabled { - Some(SandboxType::WindowsRestrictedToken) - } else { - None - } - } else { - None - } -} - fn is_write_patch_constrained_to_writable_paths( action: &ApplyPatchAction, file_system_sandbox_policy: &FileSystemSandboxPolicy, diff --git a/codex-rs/core/src/sandbox_tags.rs b/codex-rs/core/src/sandbox_tags.rs index 6a66d17dd0..57a99a8038 100644 --- a/codex-rs/core/src/sandbox_tags.rs +++ b/codex-rs/core/src/sandbox_tags.rs @@ -1,7 +1,7 @@ -use crate::exec::SandboxType; use crate::protocol::SandboxPolicy; -use crate::safety::get_platform_sandbox; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_sandboxing::SandboxType; +use codex_sandboxing::get_platform_sandbox; pub(crate) fn sandbox_tag( policy: &SandboxPolicy, diff --git a/codex-rs/core/src/sandbox_tags_tests.rs b/codex-rs/core/src/sandbox_tags_tests.rs index 7084d5ff92..40062e031b 100644 --- a/codex-rs/core/src/sandbox_tags_tests.rs +++ b/codex-rs/core/src/sandbox_tags_tests.rs @@ -1,9 +1,9 @@ use super::sandbox_tag; -use crate::exec::SandboxType; use crate::protocol::SandboxPolicy; -use crate::safety::get_platform_sandbox; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::protocol::NetworkAccess; +use codex_sandboxing::SandboxType; +use codex_sandboxing::get_platform_sandbox; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 277ff2b241..2b2f3c2364 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -1,65 +1,37 @@ /* Module: sandboxing -Build platform wrappers and produce ExecRequest for execution. Owns low-level -sandbox placement and transformation of portable CommandSpec into a -ready‑to‑spawn environment. +Core-owned adapter types for exec/runtime plumbing. Policy selection and +command transformation live in the codex-sandboxing crate; this module keeps +the exec-only metadata and translates transformed sandbox commands back into +ExecRequest for execution. */ -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; -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_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxExecRequest; +use codex_sandboxing::SandboxType; use std::collections::HashMap; -use std::collections::HashSet; -use std::path::Path; use std::path::PathBuf; #[derive(Debug)] -pub struct CommandSpec { - pub program: String, - pub args: Vec, - pub cwd: PathBuf, - pub env: HashMap, - pub expiration: ExecExpiration, - pub capture_policy: ExecCapturePolicy, - pub sandbox_permissions: SandboxPermissions, - pub additional_permissions: Option, - pub justification: Option, +pub(crate) struct ExecOptions { + pub(crate) expiration: ExecExpiration, + pub(crate) capture_policy: ExecCapturePolicy, + pub(crate) sandbox_permissions: SandboxPermissions, + pub(crate) justification: Option, } #[derive(Debug)] @@ -81,650 +53,57 @@ pub struct ExecRequest { pub arg0: Option, } -/// Bundled arguments for sandbox transformation. -/// -/// This keeps call sites self-documenting when several fields are optional. -pub(crate) struct SandboxTransformRequest<'a> { - pub spec: CommandSpec, - pub policy: &'a SandboxPolicy, - pub file_system_policy: &'a FileSystemSandboxPolicy, - pub network_policy: NetworkSandboxPolicy, - pub sandbox: SandboxType, - pub enforce_managed_network: bool, - // TODO(viyatb): Evaluate switching this to Option> - // to make shared ownership explicit across runtime/sandbox plumbing. - pub network: Option<&'a NetworkProxy>, - pub sandbox_policy_cwd: &'a Path, - #[cfg(target_os = "macos")] - pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, - pub codex_linux_sandbox_exe: Option<&'a PathBuf>, - pub use_legacy_landlock: bool, - pub windows_sandbox_level: WindowsSandboxLevel, - pub windows_sandbox_private_desktop: bool, -} - -pub enum SandboxPreference { - Auto, - Require, - Forbid, -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum SandboxTransformError { - #[error("missing codex-linux-sandbox executable path")] - MissingLinuxSandboxExecutable, - #[cfg(not(target_os = "macos"))] - #[error("seatbelt sandbox is only available on macOS")] - SeatbeltUnavailable, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct EffectiveSandboxPermissions { - pub(crate) sandbox_policy: SandboxPolicy, - pub(crate) macos_seatbelt_profile_extensions: Option, -} - -impl EffectiveSandboxPermissions { - pub(crate) fn new( - sandbox_policy: &SandboxPolicy, - macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>, - additional_permissions: Option<&PermissionProfile>, +impl ExecRequest { + pub(crate) fn from_sandbox_exec_request( + request: SandboxExecRequest, + options: ExecOptions, ) -> 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 { - 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 { - 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, - _permission_kind: &str, -) -> Vec { - 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>, - permissions: Option<&Vec>, -) -> Option> { - 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) -> Vec { - 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, Vec) { - ( - 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, - extra_writes: Vec, -) -> 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, -) -> 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; - -impl SandboxManager { - pub fn new() -> Self { - Self - } - - pub(crate) fn select_initial( - &self, - file_system_policy: &FileSystemSandboxPolicy, - network_policy: NetworkSandboxPolicy, - pref: SandboxablePreference, - windows_sandbox_level: WindowsSandboxLevel, - has_managed_network_requirements: bool, - ) -> SandboxType { - match pref { - SandboxablePreference::Forbid => SandboxType::None, - SandboxablePreference::Require => { - // Require a platform sandbox when available; on Windows this - // respects the experimental_windows_sandbox feature. - crate::safety::get_platform_sandbox( - windows_sandbox_level != WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None) - } - SandboxablePreference::Auto => { - if should_require_platform_sandbox( - file_system_policy, - network_policy, - has_managed_network_requirements, - ) { - crate::safety::get_platform_sandbox( - windows_sandbox_level != WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } - } - } - } - - pub(crate) fn transform( - &self, - request: SandboxTransformRequest<'_>, - ) -> Result { - let SandboxTransformRequest { - mut spec, - policy, - file_system_policy, - network_policy, - sandbox, - enforce_managed_network, + let SandboxExecRequest { + command, + cwd, + mut env, network, - sandbox_policy_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions, - codex_linux_sandbox_exe, - use_legacy_landlock, + sandbox, windows_sandbox_level, windows_sandbox_private_desktop, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + arg0, } = request; - #[cfg(not(target_os = "macos"))] - let macos_seatbelt_profile_extensions = None; - let additional_permissions = spec.additional_permissions.take(); - let EffectiveSandboxPermissions { - sandbox_policy: effective_policy, - macos_seatbelt_profile_extensions: _effective_macos_seatbelt_profile_extensions, - } = EffectiveSandboxPermissions::new( - policy, - 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 mut env = spec.env; - if !effective_network_policy.is_enabled() { + let ExecOptions { + expiration, + capture_policy, + sandbox_permissions, + justification, + } = options; + if !network_sandbox_policy.is_enabled() { env.insert( CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), "1".to_string(), ); } - - let mut command = Vec::with_capacity(1 + spec.args.len()); - command.push(spec.program); - command.append(&mut spec.args); - - let (command, sandbox_env, arg0_override) = match sandbox { - SandboxType::None => (command, HashMap::new(), None), - #[cfg(target_os = "macos")] - SandboxType::MacosSeatbelt => { - let mut seatbelt_env = HashMap::new(); - seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); - let mut args = create_seatbelt_command_args_for_policies_with_extensions( - command.clone(), - &effective_file_system_policy, - effective_network_policy, - sandbox_policy_cwd, - enforce_managed_network, - network, - _effective_macos_seatbelt_profile_extensions.as_ref(), - ); - let mut full_command = Vec::with_capacity(1 + args.len()); - full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); - full_command.append(&mut args); - (full_command, seatbelt_env, None) - } - #[cfg(not(target_os = "macos"))] - SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), - SandboxType::LinuxSeccomp => { - let exe = codex_linux_sandbox_exe - .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; - let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); - let mut args = create_linux_sandbox_command_args_for_policies( - command.clone(), - spec.cwd.as_path(), - &effective_policy, - &effective_file_system_policy, - effective_network_policy, - sandbox_policy_cwd, - use_legacy_landlock, - allow_proxy_network, - ); - let mut full_command = Vec::with_capacity(1 + args.len()); - full_command.push(exe.to_string_lossy().to_string()); - full_command.append(&mut args); - ( - full_command, - HashMap::new(), - Some("codex-linux-sandbox".to_string()), - ) - } - // On Windows, the restricted token sandbox executes in-process via the - // codex-windows-sandbox crate. We leave the command unchanged here and - // branch during execution based on the sandbox type. - #[cfg(target_os = "windows")] - SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), - // When building for non-Windows targets, this variant is never constructed. - #[cfg(not(target_os = "windows"))] - SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), - }; - - env.extend(sandbox_env); - - Ok(ExecRequest { + #[cfg(target_os = "macos")] + if sandbox == SandboxType::MacosSeatbelt { + env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); + } + Self { command, - cwd: spec.cwd, + cwd, env, - network: network.cloned(), - expiration: spec.expiration, - capture_policy: spec.capture_policy, + network, + expiration, + capture_policy, sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - sandbox_permissions: spec.sandbox_permissions, - sandbox_policy: effective_policy, - file_system_sandbox_policy: effective_file_system_policy, - network_sandbox_policy: effective_network_policy, - justification: spec.justification, - arg0: arg0_override, - }) - } - - pub fn denied(&self, sandbox: SandboxType, out: &ExecToolCallOutput) -> bool { - crate::exec::is_likely_sandbox_denied(sandbox, out) + sandbox_permissions, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + justification, + arg0, + } } } @@ -750,7 +129,3 @@ pub async fn execute_exec_request_with_after_spawn( let effective_policy = exec_request.sandbox_policy.clone(); execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await } - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 2d1e0a7b5d..f81016a990 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -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, @@ -45,12 +24,14 @@ pub async fn spawn_command_under_seatbelt( network: Option<&NetworkProxy>, mut env: HashMap, ) -> std::io::Result { - 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) -> Vec { - 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, - 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 }, -} - -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::>() - .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::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 { - let mut deduped_paths: BTreeMap = 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, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, -) -> Vec { - 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, -} - -fn build_seatbelt_access_policy( - action: &str, - param_prefix: &str, - roots: Vec, -) -> (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, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - 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, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - 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 = 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 { - 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 { - 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; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 563e8b3403..81978160a6 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -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; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index a6ae1ba8ca..ebfa5a8bb8 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -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 { + self.pending_input.clone() + } + pub(crate) fn has_pending_input(&self) -> bool { !self.pending_input.is_empty() } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index d9c4954cc8..c737ad63a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -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" - ), - }], - 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)]) diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 6b42be3cef..33a7c682b4 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -12,7 +12,6 @@ use uuid::Uuid; use crate::codex::TurnContext; use crate::exec::ExecCapturePolicy; use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::StreamOutput; use crate::exec::execute_exec_request; @@ -31,6 +30,7 @@ use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::user_shell_command::user_shell_command_record_item; +use codex_sandboxing::SandboxType; use super::SessionTask; use super::SessionTaskContext; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 185a32a70f..642def5559 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -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 + /// `` 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 for ForkSnapshot { + fn from(value: usize) -> Self { + Self::TruncateBeforeNthUserMessage(value) + } +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct ThreadShutdownReport { pub completed: Vec, @@ -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( &self, - nth_user_message: usize, + snapshot: S, config: Config, path: PathBuf, persist_extended_history: bool, parent_trace: Option, - ) -> CodexResult { + ) -> CodexResult + where + S: Into, + { + 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 = 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, + active_turn_start_index: Option, +} + +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) -> 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; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index e69e88fe73..40e7439ccc 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -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 = 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, + ); +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 1d799da7e7..bbe3572c26 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -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 { - crate::sandboxing::intersect_permission_profiles( - effective_permissions.clone(), - granted_permissions, - ) == *effective_permissions + intersect_permission_profiles(effective_permissions.clone(), granted_permissions) + == *effective_permissions } _ => false, }; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 873b55d660..80e5e5c2e8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -21,6 +21,7 @@ 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; @@ -105,10 +106,6 @@ fn history_contains_inter_agent_communication( }) } -fn inter_agent_message_text(recipient: &str, content: &str) -> String { - format!("author: /root\nrecipient: {recipient}\nother_recipients: []\nContent: {content}") -} - #[derive(Clone, Copy)] struct NeverEndingTask; @@ -160,6 +157,18 @@ where } } +#[derive(Debug, Deserialize)] +struct ListAgentsResult { + agents: Vec, +} + +#[derive(Debug, Deserialize)] +struct ListedAgentResult { + agent_name: String, + agent_status: serde_json::Value, + last_task_message: Option, +} + #[tokio::test] async fn handler_rejects_non_function_payloads() { let (session, turn) = make_session_and_context().await; @@ -415,47 +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"); - let expected_communication = InterAgentCommunication::new( - AgentPath::root(), - AgentPath::try_from("/root/test_process").expect("agent path"), - Vec::new(), - "continue".to_string(), + 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") ); - timeout(Duration::from_secs(2), async { - loop { - let history_items = child_thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - let recorded = - history_contains_inter_agent_communication(&history_items, &expected_communication); - 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"); + 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] @@ -492,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, @@ -532,58 +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))); - - let expected_message = inter_agent_message_text( - "/root/worker", - "[mention:$drive](app://google_drive)\nread the folder", - ); - 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 == &expected_message - )) - ) - }); - 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] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 931622fa0b..6e0e517dde 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -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; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs new file mode 100644 index 0000000000..e18547db92 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -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 { + 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, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ListAgentsResult { + agents: Vec, +} + +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") + } +} diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 2ba41ed9aa..4df8328e70 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -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; diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 4f7c3d7436..73c0b34ead 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -39,14 +39,16 @@ use crate::exec::ExecExpiration; use crate::exec_env::create_env; use crate::function_tool::FunctionCallError; use crate::original_image_detail::normalize_output_image_detail; -use crate::sandboxing::CommandSpec; -use crate::sandboxing::SandboxManager; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::sandboxing::SandboxablePreference; use crate::truncate::TruncationPolicy; use crate::truncate::truncate_text; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxablePreference; pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:"; const KERNEL_SOURCE: &str = include_str!("kernel.js"); @@ -1029,21 +1031,6 @@ impl JsReplManager { ); } - let spec = CommandSpec { - program: node_path.to_string_lossy().to_string(), - args: vec![ - "--experimental-vm-modules".to_string(), - kernel_path.to_string_lossy().to_string(), - ], - cwd: turn.cwd.clone(), - env, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: SandboxPermissions::UseDefault, - additional_permissions: None, - justification: None, - }; - let sandbox = SandboxManager::new(); let has_managed_network_requirements = turn .config @@ -1058,9 +1045,25 @@ impl JsReplManager { turn.windows_sandbox_level, has_managed_network_requirements, ); + let command = SandboxCommand { + program: node_path.to_string_lossy().to_string(), + args: vec![ + "--experimental-vm-modules".to_string(), + kernel_path.to_string_lossy().to_string(), + ], + cwd: turn.cwd.clone(), + env, + additional_permissions: None, + }; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + sandbox_permissions: SandboxPermissions::UseDefault, + justification: None, + }; let exec_env = sandbox - .transform(crate::sandboxing::SandboxTransformRequest { - spec, + .transform(SandboxTransformRequest { + command, policy: &turn.sandbox_policy, file_system_policy: &turn.file_system_sandbox_policy, network_policy: turn.network_sandbox_policy, @@ -1078,6 +1081,9 @@ impl JsReplManager { .permissions .windows_sandbox_private_desktop, }) + .map(|request| { + crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options) + }) .map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?; let mut cmd = diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4b53ac156f..09f0dc5959 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -12,7 +12,6 @@ use crate::exec::ExecToolCallOutput; use crate::guardian::GUARDIAN_REJECTION_MESSAGE; use crate::guardian::routes_approval_to_guardian; use crate::network_policy_decision::network_approval_context_from_payload; -use crate::sandboxing::SandboxManager; use crate::tools::network_approval::DeferredNetworkApproval; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::begin_network_approval; @@ -30,6 +29,8 @@ use codex_otel::ToolDecisionSource; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxType; pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, @@ -178,7 +179,7 @@ impl ToolOrchestrator { .network .is_some(); let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) { - SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None, + SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None, SandboxOverride::NoOverride => self.sandbox.select_initial( &turn_ctx.file_system_sandbox_policy, turn_ctx.network_sandbox_policy, @@ -188,8 +189,7 @@ impl ToolOrchestrator { ), }; - // Platform-specific flag gating is handled by SandboxManager::select_initial - // via crate::safety::get_platform_sandbox(..). + // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, @@ -323,7 +323,7 @@ impl ToolOrchestrator { } let escalated_attempt = SandboxAttempt { - sandbox: crate::exec::SandboxType::None, + sandbox: SandboxType::None, policy: &turn_ctx.sandbox_policy, file_system_policy: &turn_ctx.file_system_sandbox_policy, network_policy: turn_ctx.network_sandbox_policy, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index f1e9912bc5..d1a6efe001 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -9,7 +9,7 @@ use crate::exec::ExecToolCallOutput; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; -use crate::sandboxing::CommandSpec; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; @@ -17,7 +17,6 @@ use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::Sandboxable; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; @@ -28,6 +27,8 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; @@ -67,10 +68,10 @@ impl ApplyPatchRuntime { } } - fn build_command_spec( + fn build_sandbox_command( req: &ApplyPatchRequest, _codex_home: &std::path::Path, - ) -> Result { + ) -> Result { let exe = if let Some(path) = &req.codex_exe { path.clone() } else { @@ -85,21 +86,16 @@ impl ApplyPatchRuntime { })? } }; - let program = exe.to_string_lossy().to_string(); - Ok(CommandSpec { - program, + Ok(SandboxCommand { + program: exe.to_string_lossy().to_string(), args: vec![ CODEX_CORE_APPLY_PATCH_ARG1.to_string(), req.action.patch.clone(), ], cwd: req.action.cwd.clone(), - expiration: req.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), - justification: None, }) } @@ -206,9 +202,15 @@ impl ToolRuntime for ApplyPatchRuntime { attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, ) -> Result { - let spec = Self::build_command_spec(req, &ctx.turn.config.codex_home)?; + let command = Self::build_sandbox_command(req, &ctx.turn.config.codex_home)?; + let options = ExecOptions { + expiration: req.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + sandbox_permissions: req.sandbox_permissions, + justification: None, + }; let env = attempt - .env_for(spec, /*network*/ None) + .env_for(command, options, /*network*/ None) .map_err(|err| ToolError::Codex(err.into()))?; let out = execute_env(env, Self::stdout_stream(ctx)) .await diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 2335a13ab7..bc2f56a430 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -4,15 +4,12 @@ Module: runtimes Concrete ToolRuntime implementations for specific tools. Each runtime stays small and focused and reuses the orchestrator for approvals + sandbox + retry. */ -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecExpiration; use crate::path_utils; -use crate::sandboxing::CommandSpec; -use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::skills::SkillMetadata; use crate::tools::sandboxing::ToolError; use codex_protocol::models::PermissionProfile; +use codex_sandboxing::SandboxCommand; use std::collections::HashMap; use std::path::Path; @@ -28,30 +25,23 @@ pub(crate) struct ExecveSessionApproval { pub skill: Option, } -/// Shared helper to construct a CommandSpec from a tokenized command line. +/// Shared helper to construct sandbox transform inputs from a tokenized command line. /// Validates that at least a program is present. -pub(crate) fn build_command_spec( +pub(crate) fn build_sandbox_command( command: &[String], cwd: &Path, env: &HashMap, - expiration: ExecExpiration, - sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, -) -> Result { +) -> Result { let (program, args) = command .split_first() .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; - Ok(CommandSpec { + Ok(SandboxCommand { program: program.clone(), args: args.to_vec(), cwd: cwd.to_path_buf(), env: env.clone(), - expiration, - capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions, additional_permissions, - justification, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6ff8349b53..6582557f85 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -2,24 +2,26 @@ Runtime: shell Executes shell requests under the orchestrator: asks for approval when needed, -builds a CommandSpec, and runs it under the current SandboxAttempt. +builds sandbox transform inputs, and runs them under the current SandboxAttempt. */ #[cfg(unix)] pub(crate) mod unix_escalation; pub(crate) mod zsh_fork_backend; use crate::command_canonicalization::canonicalize_command_for_approval; +use crate::exec::ExecCapturePolicy; use crate::exec::ExecToolCallOutput; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; -use crate::tools::runtimes::build_command_spec; +use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -27,7 +29,6 @@ use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::Sandboxable; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; @@ -36,6 +37,7 @@ use crate::tools::sandboxing::with_cached_approval; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxablePreference; use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -243,17 +245,20 @@ impl ToolRuntime for ShellRuntime { } } - let spec = build_command_spec( + let command = build_sandbox_command( &command, &req.cwd, &req.env, - req.timeout_ms.into(), - req.sandbox_permissions, req.additional_permissions.clone(), - req.justification.clone(), )?; + let options = ExecOptions { + expiration: req.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + sandbox_permissions: req.sandbox_permissions, + justification: req.justification.clone(), + }; let env = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; let out = execute_env(env, Self::stdout_stream(ctx)) .await diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 948018dae6..a38a53ce0d 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -4,19 +4,18 @@ use crate::error::SandboxErr; use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; use crate::exec::is_likely_sandbox_denied; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; +use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::skills::SkillMetadata; use crate::tools::runtimes::ExecveSessionApproval; -use crate::tools::runtimes::build_command_spec; +use crate::tools::runtimes::build_sandbox_command; use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use codex_execpolicy::Decision; @@ -35,6 +34,11 @@ use codex_protocol::protocol::ExecApprovalRequestSkillMetadata; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; use codex_shell_command::bash::parse_shell_lc_plain_commands; use codex_shell_command::bash::parse_shell_lc_single_command_prefix; use codex_shell_escalation::EscalateServer; @@ -107,17 +111,20 @@ pub(super) async fn try_run_zsh_fork( return Ok(None); } - let spec = build_command_spec( + let command = build_sandbox_command( command, &req.cwd, &req.env, - req.timeout_ms.into(), - req.sandbox_permissions, req.additional_permissions.clone(), - req.justification.clone(), )?; + let options = ExecOptions { + expiration: req.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + sandbox_permissions: req.sandbox_permissions, + justification: req.justification.clone(), + }; let sandbox_exec_request = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; let crate::sandboxing::ExecRequest { command, @@ -1029,7 +1036,7 @@ impl CoreShellCommandExecutor { let (program, args) = command .split_first() .ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?; - let sandbox_manager = crate::sandboxing::SandboxManager::new(); + let sandbox_manager = SandboxManager::new(); let sandbox = sandbox_manager.select_initial( file_system_sandbox_policy, network_sandbox_policy, @@ -1037,37 +1044,42 @@ impl CoreShellCommandExecutor { self.windows_sandbox_level, self.network.is_some(), ); + let sandbox_permissions = if additional_permissions.is_some() { + SandboxPermissions::WithAdditionalPermissions + } else { + SandboxPermissions::UseDefault + }; + let command = SandboxCommand { + program: program.clone(), + args: args.to_vec(), + cwd: workdir.to_path_buf(), + env, + additional_permissions, + }; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + sandbox_permissions, + justification: self.justification.clone(), + }; + let exec_request = sandbox_manager.transform(SandboxTransformRequest { + command, + policy: sandbox_policy, + file_system_policy: file_system_sandbox_policy, + network_policy: network_sandbox_policy, + sandbox, + enforce_managed_network: self.network.is_some(), + network: self.network.as_ref(), + sandbox_policy_cwd: &self.sandbox_policy_cwd, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(), + use_legacy_landlock: self.use_legacy_landlock, + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: false, + })?; let mut exec_request = - sandbox_manager.transform(crate::sandboxing::SandboxTransformRequest { - spec: crate::sandboxing::CommandSpec { - program: program.clone(), - args: args.to_vec(), - cwd: workdir.to_path_buf(), - env, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: if additional_permissions.is_some() { - SandboxPermissions::WithAdditionalPermissions - } else { - SandboxPermissions::UseDefault - }, - additional_permissions, - justification: self.justification.clone(), - }, - policy: sandbox_policy, - file_system_policy: file_system_sandbox_policy, - network_policy: network_sandbox_policy, - sandbox, - enforce_managed_network: self.network.is_some(), - network: self.network.as_ref(), - sandbox_policy_cwd: &self.sandbox_policy_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(), - use_legacy_landlock: self.use_legacy_landlock, - windows_sandbox_level: self.windows_sandbox_level, - windows_sandbox_private_desktop: false, - })?; + crate::sandboxing::ExecRequest::from_sandbox_exec_request(exec_request, options); if let Some(network) = exec_request.network.as_ref() { network.apply_to_env(&mut exec_request.env); } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 37c1b53a13..6373697c3b 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -14,14 +14,11 @@ use crate::config::Constrained; use crate::config::Permissions; #[cfg(target_os = "macos")] use crate::config::types::ShellEnvironmentPolicy; -use crate::exec::SandboxType; use crate::protocol::AskForApproval; 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 +37,9 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SkillScope; +use codex_sandboxing::SandboxType; +#[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; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 49ef0e6385..f59d38556b 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,16 +7,18 @@ the process manager to spawn PTYs once an ExecRequest is prepared. use crate::command_canonicalization::canonicalize_command_for_approval; use crate::error::CodexErr; use crate::error::SandboxErr; +use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; -use crate::tools::runtimes::build_command_spec; +use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::shell::zsh_fork_backend; use crate::tools::sandboxing::Approvable; @@ -25,7 +27,6 @@ use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::Sandboxable; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; @@ -39,6 +40,7 @@ use crate::unified_exec::UnifiedExecProcessManager; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxablePreference; use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -210,18 +212,17 @@ impl<'a> ToolRuntime for UnifiedExecRunt network.apply_to_env(&mut env); } if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - let spec = build_command_spec( - &command, - &req.cwd, - &env, - ExecExpiration::DefaultTimeout, - req.sandbox_permissions, - req.additional_permissions.clone(), - req.justification.clone(), - ) - .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let command = + build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) + .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + sandbox_permissions: req.sandbox_permissions, + justification: req.justification.clone(), + }; let exec_env = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; match zsh_fork_backend::maybe_prepare_unified_exec( req, @@ -258,18 +259,17 @@ impl<'a> ToolRuntime for UnifiedExecRunt } } } - let spec = build_command_spec( - &command, - &req.cwd, - &env, - ExecExpiration::DefaultTimeout, - req.sandbox_permissions, - req.additional_permissions.clone(), - req.justification.clone(), - ) - .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let command = + build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) + .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + sandbox_permissions: req.sandbox_permissions, + justification: req.justification.clone(), + }; let exec_env = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; self.manager .open_session_with_exec_env(&exec_env, req.tty, Box::new(NoopSpawnLifecycle)) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index bf386a12d0..6cad5a3293 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -9,10 +9,8 @@ use crate::codex::TurnContext; use crate::error::CodexErr; #[cfg(test)] use crate::protocol::SandboxPolicy; -use crate::sandboxing::CommandSpec; -use crate::sandboxing::SandboxManager; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; -use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; use crate::tools::network_approval::NetworkApprovalSpec; use codex_network_proxy::NetworkProxy; @@ -23,6 +21,12 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformError; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; use futures::Future; use futures::future::BoxFuture; use serde::Serialize; @@ -280,15 +284,6 @@ pub(crate) trait Approvable { ) -> BoxFuture<'a, ReviewDecision>; } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum SandboxablePreference { - Auto, - #[allow(dead_code)] // Will be used by later tools. - Require, - #[allow(dead_code)] // Will be used by later tools. - Forbid, -} - pub(crate) trait Sandboxable { fn sandbox_preference(&self) -> SandboxablePreference; fn escalate_on_failure(&self) -> bool { @@ -323,7 +318,7 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { } pub(crate) struct SandboxAttempt<'a> { - pub sandbox: crate::exec::SandboxType, + pub sandbox: SandboxType, pub policy: &'a crate::protocol::SandboxPolicy, pub file_system_policy: &'a FileSystemSandboxPolicy, pub network_policy: NetworkSandboxPolicy, @@ -339,12 +334,13 @@ pub(crate) struct SandboxAttempt<'a> { impl<'a> SandboxAttempt<'a> { pub fn env_for( &self, - spec: CommandSpec, + command: SandboxCommand, + options: ExecOptions, network: Option<&NetworkProxy>, ) -> Result { self.manager - .transform(crate::sandboxing::SandboxTransformRequest { - spec, + .transform(SandboxTransformRequest { + command, policy: self.policy, file_system_policy: self.file_system_policy, network_policy: self.network_policy, @@ -359,6 +355,9 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) + .map(|request| { + crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options) + }) } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index b115e572e6..fdbbfa1eb7 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -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,41 +211,41 @@ fn resume_agent_output_schema() -> JsonValue { }) } -fn wait_output_schema(multi_agent_v2: bool) -> JsonValue { - if multi_agent_v2 { - 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." - } +fn wait_output_schema_v1() -> JsonValue { + json!({ + "type": "object", + "properties": { + "status": { + "type": "object", + "description": "Final statuses keyed by canonical task name when available, otherwise by agent id.", + "additionalProperties": agent_status_output_schema() }, - "required": ["message", "timed_out"], - "additionalProperties": false - }) - } else { - json!({ - "type": "object", - "properties": { - "status": { - "type": "object", - "description": "Final statuses keyed by canonical task name when available, otherwise by agent id.", - "additionalProperties": agent_status_output_schema() - }, - "timed_out": { - "type": "boolean", - "description": "Whether the wait call returned due to timeout before any agent reached a final status." - } + "timed_out": { + "type": "boolean", + "description": "Whether the wait call returned due to timeout before any agent reached a final status." + } + }, + "required": ["status", "timed_out"], + "additionalProperties": false + }) +} + +fn wait_output_schema_v2() -> JsonValue { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Brief wait summary without the agent's final content." }, - "required": ["status", "timed_out"], - "additionalProperties": false - }) - } + "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 { @@ -1440,7 +1473,7 @@ fn create_resume_agent_tool() -> ToolSpec { }) } -fn create_wait_agent_tool(multi_agent_v2: bool) -> ToolSpec { +fn wait_agent_tool_parameters() -> JsonSchema { let mut properties = BTreeMap::new(); properties.insert( "targets".to_string(), @@ -1461,23 +1494,60 @@ fn create_wait_agent_tool(multi_agent_v2: bool) -> 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: if multi_agent_v2 { - "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() - } else { - "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() + 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(multi_agent_v2)), + output_schema: Some(list_agents_output_schema()), }) } @@ -2625,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; @@ -3029,7 +3100,11 @@ pub(crate) fn build_specs_with_discoverable_tools( } push_tool_spec( &mut builder, - create_wait_agent_tool(config.multi_agent_v2), + 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, ); @@ -3040,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)); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index b33748dd35..9daaab9c6b 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -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(config.multi_agent_v2), + 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 { @@ -610,6 +625,33 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { 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"); } diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 3e69a71eea..44e926dbe6 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -12,7 +12,7 @@ //! Flow at a glance (open process) //! 1) Build a small request `{ command, cwd }`. //! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run. -//! 3) Runtime: transform `CommandSpec` -> `ExecRequest` -> spawn PTY. +//! 3) Runtime: transform `SandboxTransformRequest` -> `ExecRequest` -> spawn PTY. //! 4) If denial, orchestrator retries with `SandboxType::None`. //! 5) Process handle is returned with streaming output + metadata. //! diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 6da7c739ec..58faed27de 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -13,11 +13,11 @@ use tokio::time::Duration; use tokio_util::sync::CancellationToken; use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; use crate::exec::StreamOutput; use crate::exec::is_likely_sandbox_denied; use crate::truncate::TruncationPolicy; use crate::truncate::formatted_truncate_text; +use codex_sandboxing::SandboxType; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::SpawnedPty; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 0b7852af0e..f4f89281db 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -383,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(), diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 069e824ee5..f297312827 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -6,7 +6,6 @@ use std::string::ToString; use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecParams; use codex_core::exec::ExecToolCallOutput; -use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; @@ -14,12 +13,12 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxType; +use codex_sandboxing::get_platform_sandbox; use tempfile::TempDir; use codex_core::error::Result; -use codex_core::get_platform_sandbox; - fn skip_test() -> bool { if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) { eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test."); diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 96fef440c8..a16e460204 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -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"); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 2838233e7f..30180c8cf6 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -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 diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 55bb0ac284..0e044f3922 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -72,7 +72,7 @@ async fn wait_for_snapshot(codex_home: &Path) -> Result { } async fn wait_for_file_contents(path: &Path) -> Result { - 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), @@ -531,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![ @@ -573,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?, diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 3785235606..73fc60c39c 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -3,6 +3,7 @@ name = "codex-exec" version.workspace = true edition.workspace = true license.workspace = true +autotests = false [[bin]] name = "codex-exec" @@ -12,6 +13,10 @@ path = "src/main.rs" name = "codex_exec" path = "src/lib.rs" +[[test]] +name = "all" +path = "tests/all.rs" + [lints] workspace = true @@ -28,13 +33,10 @@ codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } -codex-utils-elapsed = { workspace = true } codex-utils-oss = { workspace = true } -codex-utils-sandbox-summary = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -shlex = { workspace = true } supports-color = { workspace = true } tokio = { workspace = true, features = [ "io-std", @@ -63,7 +65,6 @@ opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } -rmcp = { workspace = true } tempfile = { workspace = true } tracing-opentelemetry = { workspace = true } uuid = { workspace = true } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 6cda7e4082..c402371607 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -86,10 +86,6 @@ pub struct Cli { #[arg(long = "color", value_enum, default_value_t = Color::Auto)] pub color: Color, - /// Force cursor-based progress updates in exec mode. - #[arg(long = "progress-cursor", default_value_t = false)] - pub progress_cursor: bool, - /// Print events to stdout as JSONL. #[arg( long = "json", diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index fc9d123e0d..4d1da09001 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,13 +1,13 @@ use std::path::Path; +use codex_app_server_protocol::ServerNotification; use codex_core::config::Config; -use codex_protocol::protocol::Event; use codex_protocol::protocol::SessionConfiguredEvent; -pub(crate) enum CodexStatus { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexStatus { Running, InitiateShutdown, - Shutdown, } pub(crate) trait EventProcessor { @@ -19,8 +19,11 @@ pub(crate) trait EventProcessor { session_configured: &SessionConfiguredEvent, ); - /// Handle a single event emitted by the agent. - fn process_event(&mut self, event: Event) -> CodexStatus; + /// Handle a single typed app-server notification emitted by the agent. + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus; + + /// Handle a local exec warning that is not represented as an app-server notification. + fn process_warning(&mut self, message: String) -> CodexStatus; fn print_final_output(&mut self) {} } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 2b935fce95..8851233e56 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,183 +1,214 @@ +use std::io::IsTerminal; +use std::path::PathBuf; + +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TurnStatus; +use codex_core::WireApi; use codex_core::config::Config; -use codex_core::web_search::web_search_detail; -use codex_protocol::items::TurnItem; use codex_protocol::num_format::format_with_separators; -use codex_protocol::protocol::AgentMessageEvent; -use codex_protocol::protocol::AgentReasoningRawContentEvent; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::BackgroundEventEvent; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::DeprecationNoticeEvent; -use codex_protocol::protocol::ErrorEvent; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::HookCompletedEvent; -use codex_protocol::protocol::HookEventName; -use codex_protocol::protocol::HookOutputEntryKind; -use codex_protocol::protocol::HookRunStatus; -use codex_protocol::protocol::HookStartedEvent; -use codex_protocol::protocol::ItemCompletedEvent; -use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -use codex_protocol::protocol::PatchApplyBeginEvent; -use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::StreamErrorEvent; -use codex_protocol::protocol::TurnAbortReason; -use codex_protocol::protocol::TurnCompleteEvent; -use codex_protocol::protocol::TurnDiffEvent; -use codex_protocol::protocol::WarningEvent; -use codex_protocol::protocol::WebSearchEndEvent; -use codex_utils_elapsed::format_duration; -use codex_utils_elapsed::format_elapsed; use owo_colors::OwoColorize; use owo_colors::Style; -use serde::Deserialize; -use shlex::try_join; -use std::collections::HashMap; -use std::io::IsTerminal; -use std::io::Write; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_utils_sandbox_summary::create_config_summary_entries; -/// This should be configurable. When used in CI, users may not want to impose -/// a limit so they can see the full transcript. -const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20; pub(crate) struct EventProcessorWithHumanOutput { - call_id_to_patch: HashMap, - - // To ensure that --color=never is respected, ANSI escapes _must_ be added - // using .style() with one of these fields. If you need a new style, add a - // new field here. bold: Style, - italic: Style, + cyan: Style, dimmed: Style, - + green: Style, + italic: Style, magenta: Style, red: Style, - green: Style, - cyan: Style, yellow: Style, - - /// Whether to include `AgentReasoning` events in the output. show_agent_reasoning: bool, show_raw_agent_reasoning: bool, last_message_path: Option, - last_total_token_usage: Option, final_message: Option, - last_proposed_plan: Option, - progress_active: bool, - progress_last_len: usize, - use_ansi_cursor: bool, - progress_anchor: bool, - progress_done: bool, + final_message_rendered: bool, + emit_final_message_on_shutdown: bool, + last_total_token_usage: Option, } impl EventProcessorWithHumanOutput { pub(crate) fn create_with_ansi( with_ansi: bool, - cursor_ansi: bool, config: &Config, last_message_path: Option, ) -> Self { - let call_id_to_patch = HashMap::new(); + let style = |styled: Style, plain: Style| if with_ansi { styled } else { plain }; + Self { + bold: style(Style::new().bold(), Style::new()), + cyan: style(Style::new().cyan(), Style::new()), + dimmed: style(Style::new().dimmed(), Style::new()), + green: style(Style::new().green(), Style::new()), + italic: style(Style::new().italic(), Style::new()), + magenta: style(Style::new().magenta(), Style::new()), + red: style(Style::new().red(), Style::new()), + yellow: style(Style::new().yellow(), Style::new()), + show_agent_reasoning: !config.hide_agent_reasoning, + show_raw_agent_reasoning: config.show_raw_agent_reasoning, + last_message_path, + final_message: None, + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + } + } - if with_ansi { - Self { - call_id_to_patch, - bold: Style::new().bold(), - italic: Style::new().italic(), - dimmed: Style::new().dimmed(), - magenta: Style::new().magenta(), - red: Style::new().red(), - green: Style::new().green(), - cyan: Style::new().cyan(), - yellow: Style::new().yellow(), - show_agent_reasoning: !config.hide_agent_reasoning, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - last_message_path, - last_total_token_usage: None, - final_message: None, - last_proposed_plan: None, - progress_active: false, - progress_last_len: 0, - use_ansi_cursor: cursor_ansi, - progress_anchor: false, - progress_done: false, + fn render_item_started(&self, item: &ThreadItem) { + match item { + ThreadItem::CommandExecution { command, cwd, .. } => { + eprintln!( + "{}\n{} in {}", + "exec".style(self.italic).style(self.magenta), + command.style(self.bold), + cwd.display() + ); } - } else { - Self { - call_id_to_patch, - bold: Style::new(), - italic: Style::new(), - dimmed: Style::new(), - magenta: Style::new(), - red: Style::new(), - green: Style::new(), - cyan: Style::new(), - yellow: Style::new(), - show_agent_reasoning: !config.hide_agent_reasoning, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - last_message_path, - last_total_token_usage: None, - final_message: None, - last_proposed_plan: None, - progress_active: false, - progress_last_len: 0, - use_ansi_cursor: cursor_ansi, - progress_anchor: false, - progress_done: false, + ThreadItem::McpToolCall { server, tool, .. } => { + eprintln!( + "{} {} {}", + "mcp:".style(self.bold), + format!("{server}/{tool}").style(self.cyan), + "started".style(self.dimmed) + ); } + ThreadItem::WebSearch { query, .. } => { + eprintln!("{} {}", "web search:".style(self.bold), query); + } + ThreadItem::FileChange { .. } => { + eprintln!("{}", "apply patch".style(self.bold)); + } + ThreadItem::CollabAgentToolCall { tool, .. } => { + eprintln!("{} {:?}", "collab:".style(self.bold), tool); + } + _ => {} + } + } + + fn render_item_completed(&mut self, item: ThreadItem) { + match item { + ThreadItem::AgentMessage { text, .. } => { + eprintln!( + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + text + ); + self.final_message = Some(text); + self.final_message_rendered = true; + } + ThreadItem::Reasoning { + summary, content, .. + } => { + if self.show_agent_reasoning + && let Some(text) = + reasoning_text(&summary, &content, self.show_raw_agent_reasoning) + && !text.trim().is_empty() + { + eprintln!("{}", text.style(self.dimmed)); + } + } + ThreadItem::CommandExecution { + command: _, + aggregated_output, + exit_code, + status, + duration_ms, + .. + } => { + let duration_suffix = duration_ms + .map(|duration_ms| format!(" in {duration_ms}ms")) + .unwrap_or_default(); + match status { + CommandExecutionStatus::Completed => { + eprintln!( + "{}", + format!(" succeeded{duration_suffix}:").style(self.green) + ); + } + CommandExecutionStatus::Failed => { + let exit_code = exit_code.unwrap_or(1); + eprintln!( + "{}", + format!(" exited {exit_code}{duration_suffix}:").style(self.red) + ); + } + CommandExecutionStatus::Declined => { + eprintln!( + "{}", + format!(" declined{duration_suffix}:").style(self.yellow) + ); + } + CommandExecutionStatus::InProgress => { + eprintln!( + "{}", + format!(" in progress{duration_suffix}:").style(self.dimmed) + ); + } + } + if let Some(output) = aggregated_output + && !output.trim().is_empty() + { + eprintln!("{output}"); + } + } + ThreadItem::FileChange { + changes, status, .. + } => { + let status_text = match status { + PatchApplyStatus::Completed => "completed", + PatchApplyStatus::Failed => "failed", + PatchApplyStatus::Declined => "declined", + PatchApplyStatus::InProgress => "in_progress", + }; + eprintln!("{} {}", "patch:".style(self.bold), status_text); + for change in changes { + eprintln!("{}", change.path.style(self.dimmed)); + } + } + ThreadItem::McpToolCall { + server, + tool, + status, + error, + .. + } => { + let status_text = match status { + McpToolCallStatus::Completed => "completed".style(self.green), + McpToolCallStatus::Failed => "failed".style(self.red), + McpToolCallStatus::InProgress => "in_progress".style(self.dimmed), + }; + eprintln!( + "{} {} {}", + "mcp:".style(self.bold), + format!("{server}/{tool}").style(self.cyan), + format!("({status_text})").style(self.dimmed) + ); + if let Some(error) = error { + eprintln!("{}", error.message.style(self.red)); + } + } + ThreadItem::WebSearch { query, .. } => { + eprintln!("{} {}", "web search:".style(self.bold), query); + } + ThreadItem::ContextCompaction { .. } => { + eprintln!("{}", "context compacted".style(self.dimmed)); + } + _ => {} } } } -#[derive(Debug, Deserialize)] -struct AgentJobProgressMessage { - job_id: String, - total_items: usize, - pending_items: usize, - running_items: usize, - completed_items: usize, - failed_items: usize, - eta_seconds: Option, -} - -struct PatchApplyBegin { - start_time: Instant, - auto_approved: bool, -} - -/// Timestamped helper. The timestamp is styled with self.dimmed. -macro_rules! ts_msg { - ($self:ident, $($arg:tt)*) => {{ - eprintln!($($arg)*); - }}; -} - impl EventProcessor for EventProcessorWithHumanOutput { - /// Print a concise summary of the effective configuration that will be used - /// for the session. This mirrors the information shown in the TUI welcome - /// screen. fn print_config_summary( &mut self, config: &Config, @@ -185,979 +216,336 @@ impl EventProcessor for EventProcessorWithHumanOutput { session_configured_event: &SessionConfiguredEvent, ) { const VERSION: &str = env!("CARGO_PKG_VERSION"); - ts_msg!( - self, - "OpenAI Codex v{} (research preview)\n--------", - VERSION - ); - - let mut entries = - create_config_summary_entries(config, session_configured_event.model.as_str()); - entries.push(( - "session id", - session_configured_event.session_id.to_string(), - )); - - for (key, value) in entries { + eprintln!("OpenAI Codex v{VERSION} (research preview)\n--------"); + for (key, value) in config_summary_entries(config, session_configured_event) { eprintln!("{} {}", format!("{key}:").style(self.bold), value); } - eprintln!("--------"); - - // Echo the prompt that will be sent to the agent so it is visible in the - // transcript/logs before any events come in. Note the prompt may have been - // read from stdin, so it may not be visible in the terminal otherwise. - ts_msg!(self, "{}\n{}", "user".style(self.cyan), prompt); + eprintln!("{}\n{}", "user".style(self.cyan), prompt); } - fn process_event(&mut self, event: Event) -> CodexStatus { - let Event { id: _, msg } = event; - if let EventMsg::BackgroundEvent(BackgroundEventEvent { message }) = &msg - && let Some(update) = Self::parse_agent_job_progress(message) - { - self.render_agent_job_progress(update); - return CodexStatus::Running; - } - if self.progress_active && !Self::should_interrupt_progress(&msg) { - return CodexStatus::Running; - } - if !Self::is_silent_event(&msg) { - self.finish_progress_line(); - } - match msg { - EventMsg::Error(ErrorEvent { message, .. }) => { - let prefix = "ERROR:".style(self.red); - ts_msg!(self, "{prefix} {message}"); - } - EventMsg::Warning(WarningEvent { message }) => { - ts_msg!( - self, - "{} {message}", - "warning:".style(self.yellow).style(self.bold) + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus { + match notification { + ServerNotification::ConfigWarning(notification) => { + let details = notification + .details + .map(|details| format!(" ({details})")) + .unwrap_or_default(); + eprintln!( + "{} {}{}", + "warning:".style(self.yellow).style(self.bold), + notification.summary, + details ); + CodexStatus::Running } - EventMsg::GuardianAssessment(_) => {} - EventMsg::ModelReroute(_) => {} - EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { - ts_msg!( - self, - "{} {summary}", - "deprecated:".style(self.magenta).style(self.bold) - ); - if let Some(details) = details { - ts_msg!(self, " {}", details.style(self.dimmed)); - } - } - EventMsg::McpStartupUpdate(update) => { - let status_text = match update.status { - codex_protocol::protocol::McpStartupStatus::Starting => "starting".to_string(), - codex_protocol::protocol::McpStartupStatus::Ready => "ready".to_string(), - codex_protocol::protocol::McpStartupStatus::Cancelled => { - "cancelled".to_string() - } - codex_protocol::protocol::McpStartupStatus::Failed { ref error } => { - format!("failed: {error}") - } - }; - ts_msg!( - self, - "{} {} {}", - "mcp:".style(self.cyan), - update.server, - status_text - ); - } - EventMsg::McpStartupComplete(summary) => { - let mut parts = Vec::new(); - if !summary.ready.is_empty() { - parts.push(format!("ready: {}", summary.ready.join(", "))); - } - if !summary.failed.is_empty() { - let servers: Vec<_> = summary.failed.iter().map(|f| f.server.clone()).collect(); - parts.push(format!("failed: {}", servers.join(", "))); - } - if !summary.cancelled.is_empty() { - parts.push(format!("cancelled: {}", summary.cancelled.join(", "))); - } - let joined = if parts.is_empty() { - "no servers".to_string() - } else { - parts.join("; ") - }; - ts_msg!(self, "{} {}", "mcp startup:".style(self.cyan), joined); - } - EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { - ts_msg!(self, "{}", message.style(self.dimmed)); - } - EventMsg::StreamError(StreamErrorEvent { - message, - additional_details, - .. - }) => { - let message = match additional_details { - Some(details) if !details.trim().is_empty() => format!("{message} ({details})"), - _ => message, - }; - ts_msg!(self, "{}", message.style(self.dimmed)); - } - EventMsg::TurnStarted(_) => { - // Ignore. - } - EventMsg::ElicitationRequest(ev) => { - ts_msg!( - self, + ServerNotification::Error(notification) => { + eprintln!( "{} {}", - "elicitation request".style(self.magenta), - ev.server_name.style(self.dimmed) - ); - ts_msg!( - self, - "{}", - "auto-cancelling (not supported in exec mode)".style(self.dimmed) + "ERROR:".style(self.red).style(self.bold), + notification.error ); + CodexStatus::Running } - EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message, .. - }) => { - let last_message = last_agent_message - .as_deref() - .or(self.last_proposed_plan.as_deref()); - if let Some(output_file) = self.last_message_path.as_deref() { - handle_last_message(last_message, output_file); - } - - self.final_message = last_agent_message.or_else(|| self.last_proposed_plan.clone()); - - return CodexStatus::InitiateShutdown; - } - EventMsg::TokenCount(ev) => { - self.last_total_token_usage = ev.info; - } - - EventMsg::AgentReasoningSectionBreak(_) => { - if !self.show_agent_reasoning { - return CodexStatus::Running; - } - eprintln!(); - } - EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - if self.show_raw_agent_reasoning { - ts_msg!( - self, - "{}\n{}", - "thinking".style(self.italic).style(self.magenta), - text, - ); - } - } - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { - ts_msg!( - self, - "{}\n{}", - "codex".style(self.italic).style(self.magenta), - message, - ); - } - EventMsg::ItemCompleted(ItemCompletedEvent { - item: TurnItem::Plan(item), - .. - }) => { - self.last_proposed_plan = Some(item.text); - } - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => { - eprint!( - "{}\n{} in {}", - "exec".style(self.italic).style(self.magenta), - escape_command(&command).style(self.bold), - cwd.to_string_lossy(), - ); - } - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - aggregated_output, - duration, - exit_code, - .. - }) => { - let duration = format!(" in {}", format_duration(duration)); - - let truncated_output = aggregated_output - .lines() - .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) - .collect::>() - .join("\n"); - match exit_code { - 0 => { - let title = format!(" succeeded{duration}:"); - ts_msg!(self, "{}", title.style(self.green)); - } - _ => { - let title = format!(" exited {exit_code}{duration}:"); - ts_msg!(self, "{}", title.style(self.red)); - } - } - eprintln!("{}", truncated_output.style(self.dimmed)); - } - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: _, - invocation, - }) => { - ts_msg!( - self, + ServerNotification::DeprecationNotice(notification) => { + eprintln!( "{} {}", - "tool".style(self.magenta), - format_mcp_invocation(&invocation).style(self.bold), + "deprecated:".style(self.yellow).style(self.bold), + notification.summary ); - } - EventMsg::McpToolCallEnd(tool_call_end_event) => { - let is_success = tool_call_end_event.is_success(); - let McpToolCallEndEvent { - call_id: _, - result, - invocation, - duration, - } = tool_call_end_event; - - let duration = format!(" in {}", format_duration(duration)); - - let status_str = if is_success { "success" } else { "failed" }; - let title_style = if is_success { self.green } else { self.red }; - let title = format!( - "{} {status_str}{duration}:", - format_mcp_invocation(&invocation) - ); - - ts_msg!(self, "{}", title.style(title_style)); - - if let Ok(res) = result { - let val = serde_json::to_value(res) - .unwrap_or_else(|_| serde_json::Value::String("".to_string())); - let pretty = - serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); - - for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { - eprintln!("{}", line.style(self.dimmed)); - } + if let Some(details) = notification.details { + eprintln!("{}", details.style(self.dimmed)); } + CodexStatus::Running } - EventMsg::WebSearchBegin(_) => { - ts_msg!(self, "🌐 Searching the web..."); - } - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: _, - query, - action, - }) => { - let detail = web_search_detail(Some(&action), &query); - if detail.is_empty() { - ts_msg!(self, "🌐 Searched the web"); - } else { - ts_msg!(self, "🌐 Searched: {detail}"); - } - } - EventMsg::ImageGenerationBegin(generated) => { - ts_msg!( - self, + ServerNotification::HookStarted(notification) => { + eprintln!( "{} {}", - "image generation started".style(self.magenta), - generated.call_id + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed) ); + CodexStatus::Running } - EventMsg::ImageGenerationEnd(generated) => { - if !generated.result.is_empty() - && !generated.result.starts_with("data:") - && !generated.result.starts_with("http://") - && !generated.result.starts_with("https://") - && !generated.result.starts_with("file://") - { - ts_msg!( - self, - "{} {} {}", - "generated image".style(self.magenta), - generated.call_id, - generated.result.style(self.dimmed) - ); - } else { - ts_msg!( - self, - "{} {}", - "generated image".style(self.magenta), - generated.call_id - ); - } + ServerNotification::HookCompleted(notification) => { + eprintln!( + "{} {} {:?}", + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed), + notification.run.status + ); + CodexStatus::Running } - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved, - changes, - .. - }) => { - // Store metadata so we can calculate duration later when we - // receive the corresponding PatchApplyEnd event. - self.call_id_to_patch.insert( - call_id, - PatchApplyBegin { - start_time: Instant::now(), - auto_approved, - }, + ServerNotification::ItemStarted(notification) => { + self.render_item_started(¬ification.item); + CodexStatus::Running + } + ServerNotification::ItemCompleted(notification) => { + self.render_item_completed(notification.item); + CodexStatus::Running + } + ServerNotification::ModelRerouted(notification) => { + eprintln!( + "{} {} -> {}", + "model rerouted:".style(self.yellow).style(self.bold), + notification.from_model, + notification.to_model ); - - ts_msg!( - self, - "{}", - "file update".style(self.magenta).style(self.italic), - ); - - // Pretty-print the patch summary with colored diff markers so - // it's easy to scan in the terminal output. - for (path, change) in changes.iter() { - match change { - FileChange::Add { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - eprintln!("{}", header.style(self.magenta)); - for line in content.lines() { - eprintln!("{}", line.style(self.green)); - } - } - FileChange::Delete { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - eprintln!("{}", header.style(self.magenta)); - for line in content.lines() { - eprintln!("{}", line.style(self.red)); - } - } - FileChange::Update { - unified_diff, - move_path, - } => { - let header = if let Some(dest) = move_path { - format!( - "{} {} -> {}", - format_file_change(change), - path.to_string_lossy(), - dest.to_string_lossy() - ) - } else { - format!("{} {}", format_file_change(change), path.to_string_lossy()) - }; - eprintln!("{}", header.style(self.magenta)); - - // Colorize diff lines. We keep file header lines - // (--- / +++) without extra coloring so they are - // still readable. - for diff_line in unified_diff.lines() { - if diff_line.starts_with('+') && !diff_line.starts_with("+++") { - eprintln!("{}", diff_line.style(self.green)); - } else if diff_line.starts_with('-') - && !diff_line.starts_with("---") - { - eprintln!("{}", diff_line.style(self.red)); - } else { - eprintln!("{diff_line}"); - } - } - } + CodexStatus::Running + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + ServerNotification::TurnCompleted(notification) => match notification.turn.status { + TurnStatus::Completed => { + let rendered_message = self + .final_message_rendered + .then(|| self.final_message.clone()) + .flatten(); + if let Some(final_message) = + final_message_from_turn_items(notification.turn.items.as_slice()) + { + self.final_message_rendered = + rendered_message.as_deref() == Some(final_message.as_str()); + self.final_message = Some(final_message); } + self.emit_final_message_on_shutdown = true; + CodexStatus::InitiateShutdown } - } - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id, - stdout, - stderr, - success, - .. - }) => { - let patch_begin = self.call_id_to_patch.remove(&call_id); - - // Compute duration and summary label similar to exec commands. - let (duration, label) = if let Some(PatchApplyBegin { - start_time, - auto_approved, - }) = patch_begin - { - ( - format!(" in {}", format_elapsed(start_time)), - format!("apply_patch(auto_approved={auto_approved})"), - ) - } else { - (String::new(), format!("apply_patch('{call_id}')")) - }; - - let (exit_code, output, title_style) = if success { - (0, stdout, self.green) - } else { - (1, stderr, self.red) - }; - - let title = format!("{label} exited {exit_code}{duration}:"); - ts_msg!(self, "{}", title.style(title_style)); - for line in output.lines() { - eprintln!("{}", line.style(self.dimmed)); + TurnStatus::Failed => { + self.final_message = None; + self.final_message_rendered = false; + self.emit_final_message_on_shutdown = false; + if let Some(error) = notification.turn.error { + eprintln!("{} {}", "ERROR:".style(self.red).style(self.bold), error); + } + CodexStatus::InitiateShutdown } - } - EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => { - ts_msg!( - self, - "{}", - "file update:".style(self.magenta).style(self.italic) - ); - eprintln!("{unified_diff}"); - } - EventMsg::AgentReasoning(agent_reasoning_event) => { - if self.show_agent_reasoning { - ts_msg!( - self, - "{}\n{}", - "thinking".style(self.italic).style(self.magenta), - agent_reasoning_event.text, - ); + TurnStatus::Interrupted => { + self.final_message = None; + self.final_message_rendered = false; + self.emit_final_message_on_shutdown = false; + eprintln!("{}", "turn interrupted".style(self.dimmed)); + CodexStatus::InitiateShutdown } - } - EventMsg::SessionConfigured(session_configured_event) => { - let SessionConfiguredEvent { - session_id: conversation_id, - model, - .. - } = session_configured_event; - - ts_msg!( - self, - "{} {}", - "codex session".style(self.magenta).style(self.bold), - conversation_id.to_string().style(self.dimmed) - ); - - ts_msg!(self, "model: {}", model); - eprintln!(); - } - EventMsg::PlanUpdate(plan_update_event) => { - let UpdatePlanArgs { explanation, plan } = plan_update_event; - - // Header - ts_msg!(self, "{}", "Plan update".style(self.magenta)); - - // Optional explanation - if let Some(explanation) = explanation - && !explanation.trim().is_empty() - { - ts_msg!(self, "{}", explanation.style(self.italic)); + TurnStatus::InProgress => CodexStatus::Running, + }, + ServerNotification::TurnDiffUpdated(notification) => { + if !notification.diff.trim().is_empty() { + eprintln!("{}", notification.diff); } - - // Pretty-print the plan items with simple status markers. - for item in plan { - match item.status { - StepStatus::Completed => { - ts_msg!(self, " {} {}", "✓".style(self.green), item.step); + CodexStatus::Running + } + ServerNotification::TurnPlanUpdated(notification) => { + if let Some(explanation) = notification.explanation { + eprintln!("{}", explanation.style(self.italic)); + } + for step in notification.plan { + match step.status { + codex_app_server_protocol::TurnPlanStepStatus::Completed => { + eprintln!(" {} {}", "✓".style(self.green), step.step); } - StepStatus::InProgress => { - ts_msg!(self, " {} {}", "→".style(self.cyan), item.step); + codex_app_server_protocol::TurnPlanStepStatus::InProgress => { + eprintln!(" {} {}", "→".style(self.cyan), step.step); } - StepStatus::Pending => { - ts_msg!( - self, + codex_app_server_protocol::TurnPlanStepStatus::Pending => { + eprintln!( " {} {}", "•".style(self.dimmed), - item.step.style(self.dimmed) + step.step.style(self.dimmed) ); } } } + CodexStatus::Running } - EventMsg::ViewImageToolCall(view) => { - ts_msg!( - self, - "{} {}", - "viewed image".style(self.magenta), - view.path.display() - ); - } - EventMsg::TurnAborted(abort_reason) => { - match abort_reason.reason { - TurnAbortReason::Interrupted => { - ts_msg!(self, "task interrupted"); - } - TurnAbortReason::Replaced => { - ts_msg!(self, "task aborted: replaced by a new task"); - } - TurnAbortReason::ReviewEnded => { - ts_msg!(self, "task aborted: review ended"); - } - } - return CodexStatus::InitiateShutdown; - } - EventMsg::ContextCompacted(_) => { - ts_msg!(self, "context compacted"); - } - EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id, - sender_thread_id: _, - prompt, - .. - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("spawn_agent", &call_id, Some(&prompt)) - .style(self.bold) - ); - } - EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { - call_id, - sender_thread_id: _, - new_thread_id, - prompt, - status, - .. - }) => { - let success = new_thread_id.is_some() && !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("spawn_agent", &call_id, Some(&prompt)), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - if let Some(new_thread_id) = new_thread_id { - eprintln!(" agent: {}", new_thread_id.to_string().style(self.dimmed)); - } - } - EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - prompt, - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("send_input", &call_id, Some(&prompt)) - .style(self.bold) - ); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - prompt, - status, - .. - }) => { - let success = !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("send_input", &call_id, Some(&prompt)), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabWaitingBegin(CollabWaitingBeginEvent { - sender_thread_id: _, - receiver_thread_ids, - call_id, - .. - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("wait", &call_id, /*prompt*/ None).style(self.bold) - ); - eprintln!( - " receivers: {}", - format_receiver_list(&receiver_thread_ids).style(self.dimmed) - ); - } - EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { - sender_thread_id: _, - call_id, - statuses, - .. - }) => { - if statuses.is_empty() { - ts_msg!( - self, - "{} {}:", - format_collab_invocation("wait", &call_id, /*prompt*/ None), - "timed out".style(self.yellow) - ); - return CodexStatus::Running; - } - let success = !statuses.values().any(is_collab_status_failure); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {} agents complete:", - format_collab_invocation("wait", &call_id, /*prompt*/ None), - statuses.len() - ); - ts_msg!(self, "{}", title.style(title_style)); - let mut sorted = statuses - .into_iter() - .map(|(thread_id, status)| (thread_id.to_string(), status)) - .collect::>(); - sorted.sort_by(|(left, _), (right, _)| left.cmp(right)); - for (thread_id, status) in sorted { - eprintln!( - " {} {}", - thread_id.style(self.dimmed), - format_collab_status(&status).style(style_for_agent_status(&status, self)) - ); - } - } - EventMsg::CollabCloseBegin(CollabCloseBeginEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("close_agent", &call_id, /*prompt*/ None) - .style(self.bold) - ); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabCloseEnd(CollabCloseEndEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - status, - .. - }) => { - let success = !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("close_agent", &call_id, /*prompt*/ None), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::HookStarted(event) => self.render_hook_started(event), - EventMsg::HookCompleted(event) => self.render_hook_completed(event), - EventMsg::ShutdownComplete => return CodexStatus::Shutdown, - EventMsg::ThreadNameUpdated(_) - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) - | EventMsg::ListSkillsResponse(_) - | EventMsg::RawResponseItem(_) - | EventMsg::UserMessage(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::AgentReasoningRawContentDelta(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable - | EventMsg::UndoCompleted(_) - | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) - | EventMsg::RequestPermissions(_) - | EventMsg::CollabResumeBegin(_) - | EventMsg::CollabResumeEnd(_) - | EventMsg::RealtimeConversationStarted(_) - | EventMsg::RealtimeConversationRealtime(_) - | EventMsg::RealtimeConversationClosed(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) => {} + ServerNotification::TurnStarted(_) => CodexStatus::Running, + _ => CodexStatus::Running, } + } + + fn process_warning(&mut self, message: String) -> CodexStatus { + eprintln!( + "{} {message}", + "warning:".style(self.yellow).style(self.bold) + ); CodexStatus::Running } fn print_final_output(&mut self) { - self.finish_progress_line(); - if let Some(usage_info) = &self.last_total_token_usage { + if self.emit_final_message_on_shutdown + && let Some(path) = self.last_message_path.as_deref() + { + handle_last_message(self.final_message.as_deref(), path); + } + + if let Some(usage) = &self.last_total_token_usage { eprintln!( "{}\n{}", - "tokens used".style(self.magenta).style(self.italic), - format_with_separators(usage_info.total_token_usage.blended_total()) + "tokens used".style(self.dimmed), + format_with_separators(blended_total(usage)) ); } - // In interactive terminals we already emitted the final assistant - // message on stderr during event processing. Preserve stdout emission - // only for non-interactive use so pipes and scripts still receive the - // final message. #[allow(clippy::print_stdout)] if should_print_final_message_to_stdout( - self.final_message.as_deref(), + self.emit_final_message_on_shutdown + .then_some(self.final_message.as_deref()) + .flatten(), std::io::stdout().is_terminal(), std::io::stderr().is_terminal(), - ) && let Some(message) = &self.final_message + ) && let Some(message) = self.final_message.as_deref() { - if message.ends_with('\n') { - print!("{message}"); - } else { - println!("{message}"); - } + println!("{message}"); + } else if should_print_final_message_to_tty( + self.emit_final_message_on_shutdown + .then_some(self.final_message.as_deref()) + .flatten(), + self.final_message_rendered, + std::io::stdout().is_terminal(), + std::io::stderr().is_terminal(), + ) && let Some(message) = self.final_message.as_deref() + { + eprintln!( + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + message + ); } } } -impl EventProcessorWithHumanOutput { - fn parse_agent_job_progress(message: &str) -> Option { - let payload = message.strip_prefix("agent_job_progress:")?; - serde_json::from_str::(payload).ok() +fn config_summary_entries( + config: &Config, + session_configured_event: &SessionConfiguredEvent, +) -> Vec<(&'static str, String)> { + let mut entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", session_configured_event.model.clone()), + ( + "provider", + session_configured_event.model_provider_id.clone(), + ), + ( + "approval", + config.permissions.approval_policy.value().to_string(), + ), + ( + "sandbox", + summarize_sandbox_policy(config.permissions.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + entries.push(( + "reasoning effort", + config + .model_reasoning_effort + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()), + )); + entries.push(( + "reasoning summaries", + config + .model_reasoning_summary + .map(|summary| summary.to_string()) + .unwrap_or_else(|| "none".to_string()), + )); } + entries.push(( + "session id", + session_configured_event.session_id.to_string(), + )); + entries +} - fn render_hook_started(&self, event: HookStartedEvent) { - if !Self::should_print_hook_started(&event) { - return; +fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly { network_access, .. } => { + let mut summary = "read-only".to_string(); + if *network_access { + summary.push_str(" (network access enabled)"); + } + summary } - let event_name = Self::hook_event_name(event.run.event_name); - if let Some(status_message) = event.run.status_message - && !status_message.trim().is_empty() - { - ts_msg!( - self, - "{} {}: {}", - "hook".style(self.magenta), - event_name, - status_message + SandboxPolicy::ExternalSandbox { network_access } => { + let mut summary = "external-sandbox".to_string(); + if matches!( + network_access, + codex_protocol::protocol::NetworkAccess::Enabled + ) { + summary.push_str(" (network access enabled)"); + } + summary + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + read_only_access: _, + } => { + let mut summary = "workspace-write".to_string(); + let mut writable_entries = vec!["workdir".to_string()]; + if !*exclude_slash_tmp { + writable_entries.push("/tmp".to_string()); + } + if !*exclude_tmpdir_env_var { + writable_entries.push("$TMPDIR".to_string()); + } + writable_entries.extend( + writable_roots + .iter() + .map(|path| path.to_string_lossy().to_string()), ); - } - } - - fn render_hook_completed(&self, event: HookCompletedEvent) { - if !Self::should_print_hook_completed(&event) { - return; - } - - let event_name = Self::hook_event_name(event.run.event_name); - let status = Self::hook_status_name(event.run.status); - ts_msg!( - self, - "{} {} ({status})", - "hook".style(self.magenta), - event_name - ); - - for entry in event.run.entries { - let prefix = Self::hook_entry_prefix(entry.kind); - eprintln!(" {prefix} {}", entry.text); - } - } - - fn should_print_hook_started(event: &HookStartedEvent) -> bool { - event - .run - .status_message - .as_deref() - .is_some_and(|status_message| !status_message.trim().is_empty()) - } - - fn should_print_hook_completed(event: &HookCompletedEvent) -> bool { - event.run.status != HookRunStatus::Completed || !event.run.entries.is_empty() - } - - fn hook_event_name(event_name: HookEventName) -> &'static str { - match event_name { - HookEventName::PreToolUse => "PreToolUse", - HookEventName::SessionStart => "SessionStart", - HookEventName::UserPromptSubmit => "UserPromptSubmit", - HookEventName::Stop => "Stop", - } - } - - fn hook_status_name(status: HookRunStatus) -> &'static str { - match status { - HookRunStatus::Running => "running", - HookRunStatus::Completed => "completed", - HookRunStatus::Failed => "failed", - HookRunStatus::Blocked => "blocked", - HookRunStatus::Stopped => "stopped", - } - } - - fn hook_entry_prefix(kind: HookOutputEntryKind) -> &'static str { - match kind { - HookOutputEntryKind::Warning => "warning:", - HookOutputEntryKind::Stop => "stop:", - HookOutputEntryKind::Feedback => "feedback:", - HookOutputEntryKind::Context => "context:", - HookOutputEntryKind::Error => "error:", - } - } - - fn is_silent_event(msg: &EventMsg) -> bool { - match msg { - EventMsg::HookStarted(event) => !Self::should_print_hook_started(event), - EventMsg::HookCompleted(event) => !Self::should_print_hook_completed(event), - _ => matches!( - msg, - EventMsg::ThreadNameUpdated(_) - | EventMsg::TokenCount(_) - | EventMsg::TurnStarted(_) - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) - | EventMsg::ListSkillsResponse(_) - | EventMsg::RawResponseItem(_) - | EventMsg::UserMessage(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::AgentReasoningRawContentDelta(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable - | EventMsg::UndoCompleted(_) - | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) - | EventMsg::RequestPermissions(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) - | EventMsg::GuardianAssessment(_) - ), - } - } - - fn should_interrupt_progress(msg: &EventMsg) -> bool { - if let EventMsg::HookCompleted(event) = msg { - return Self::should_print_hook_completed(event); - } - matches!( - msg, - EventMsg::Error(_) - | EventMsg::Warning(_) - | EventMsg::GuardianAssessment(_) - | EventMsg::DeprecationNotice(_) - | EventMsg::StreamError(_) - | EventMsg::TurnComplete(_) - | EventMsg::ShutdownComplete - ) - } - - fn finish_progress_line(&mut self) { - if self.progress_active { - self.progress_active = false; - self.progress_last_len = 0; - self.progress_done = false; - if self.use_ansi_cursor { - if self.progress_anchor { - eprintln!("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); - } else { - eprintln!("\u{1b}[1G\u{1b}[2K"); - } - } else { - eprintln!(); + summary.push_str(&format!(" [{}]", writable_entries.join(", "))); + if *network_access { + summary.push_str(" (network access enabled)"); } - self.progress_anchor = false; + summary } } +} - fn render_agent_job_progress(&mut self, update: AgentJobProgressMessage) { - let total = update.total_items.max(1); - let processed = update.completed_items + update.failed_items; - let percent = (processed as f64 / total as f64 * 100.0).round() as i64; - let job_label = update.job_id.chars().take(8).collect::(); - let eta = update - .eta_seconds - .map(|secs| format_duration(Duration::from_secs(secs))) - .unwrap_or_else(|| "--".to_string()); - let columns = std::env::var("COLUMNS") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0); - let line = format_agent_job_progress_line( - columns, - job_label.as_str(), - AgentJobProgressStats { - processed, - total, - percent, - failed: update.failed_items, - running: update.running_items, - pending: update.pending_items, - }, - eta.as_str(), - ); - let done = processed >= update.total_items; - if !self.use_ansi_cursor { - eprintln!("{line}"); - if done { - self.progress_active = false; - self.progress_last_len = 0; - } - return; - } - if done && self.progress_done { - return; - } - if !self.progress_active { - eprintln!(); - self.progress_anchor = true; - self.progress_done = false; - } - let mut output = String::new(); - if self.progress_anchor { - output.push_str("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); - } else { - output.push_str("\u{1b}[1G\u{1b}[2K"); - } - output.push_str(&line); - if done { - output.push('\n'); - eprint!("{output}"); - self.progress_active = false; - self.progress_last_len = 0; - self.progress_anchor = false; - self.progress_done = true; - return; - } - eprint!("{output}"); - let _ = std::io::stderr().flush(); - self.progress_active = true; - self.progress_last_len = line.len(); +fn reasoning_text( + summary: &[String], + content: &[String], + show_raw_agent_reasoning: bool, +) -> Option { + let entries = if show_raw_agent_reasoning && !content.is_empty() { + content + } else { + summary + }; + if entries.is_empty() { + None + } else { + Some(entries.join("\n")) } } +fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { + items + .iter() + .rev() + .find_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) + .or_else(|| { + items.iter().rev().find_map(|item| match item { + ThreadItem::Plan { text, .. } => Some(text.clone()), + _ => None, + }) + }) +} + +fn blended_total(usage: &ThreadTokenUsage) -> i64 { + let cached_input = usage.total.cached_input_tokens.max(0); + let non_cached_input = (usage.total.input_tokens - cached_input).max(0); + (non_cached_input + usage.total.output_tokens.max(0)).max(0) +} + fn should_print_final_message_to_stdout( final_message: Option<&str>, stdout_is_terminal: bool, @@ -1166,293 +554,357 @@ fn should_print_final_message_to_stdout( final_message.is_some() && !(stdout_is_terminal && stderr_is_terminal) } -struct AgentJobProgressStats { - processed: usize, - total: usize, - percent: i64, - failed: usize, - running: usize, - pending: usize, -} - -fn format_agent_job_progress_line( - columns: Option, - job_label: &str, - stats: AgentJobProgressStats, - eta: &str, -) -> String { - let rest = format!( - "{processed}/{total} {percent}% f{failed} r{running} p{pending} eta {eta}", - processed = stats.processed, - total = stats.total, - percent = stats.percent, - failed = stats.failed, - running = stats.running, - pending = stats.pending - ); - let prefix = format!("job {job_label}"); - let base_len = prefix.len() + rest.len() + 4; - let mut bar_width = columns - .and_then(|columns| columns.checked_sub(base_len)) - .filter(|available| *available > 0) - .unwrap_or(20usize); - let with_bar = |width: usize| { - let filled = ((stats.processed as f64 / stats.total as f64) * width as f64) - .round() - .clamp(0.0, width as f64) as usize; - let mut bar = "#".repeat(filled); - bar.push_str(&"-".repeat(width - filled)); - format!("{prefix} [{bar}] {rest}") - }; - let mut line = with_bar(bar_width); - if let Some(columns) = columns - && line.len() > columns - { - let min_line = format!("{prefix} {rest}"); - if min_line.len() > columns { - let mut truncated = min_line; - if columns > 2 && truncated.len() > columns { - truncated.truncate(columns - 2); - truncated.push_str(".."); - } - return truncated; - } - let available = columns.saturating_sub(base_len); - if available == 0 { - return min_line; - } - bar_width = available.min(bar_width).max(1); - line = with_bar(bar_width); - } - line -} - -fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) -} - -fn format_file_change(change: &FileChange) -> &'static str { - match change { - FileChange::Add { .. } => "A", - FileChange::Delete { .. } => "D", - FileChange::Update { - move_path: Some(_), .. - } => "R", - FileChange::Update { - move_path: None, .. - } => "M", - } -} - -fn format_collab_invocation(tool: &str, call_id: &str, prompt: Option<&str>) -> String { - let prompt = prompt - .map(str::trim) - .filter(|prompt| !prompt.is_empty()) - .map(|prompt| truncate_preview(prompt, /*max_chars*/ 120)); - match prompt { - Some(prompt) => format!("{tool}({call_id}, prompt=\"{prompt}\")"), - None => format!("{tool}({call_id})"), - } -} - -fn format_collab_status(status: &AgentStatus) -> String { - match status { - AgentStatus::PendingInit => "pending init".to_string(), - AgentStatus::Running => "running".to_string(), - AgentStatus::Interrupted => "interrupted".to_string(), - AgentStatus::Completed(Some(message)) => { - let preview = truncate_preview(message.trim(), /*max_chars*/ 120); - if preview.is_empty() { - "completed".to_string() - } else { - format!("completed: \"{preview}\"") - } - } - AgentStatus::Completed(None) => "completed".to_string(), - AgentStatus::Errored(message) => { - let preview = truncate_preview(message.trim(), /*max_chars*/ 120); - if preview.is_empty() { - "errored".to_string() - } else { - format!("errored: \"{preview}\"") - } - } - AgentStatus::Shutdown => "shutdown".to_string(), - AgentStatus::NotFound => "not found".to_string(), - } -} - -fn style_for_agent_status( - status: &AgentStatus, - processor: &EventProcessorWithHumanOutput, -) -> Style { - match status { - AgentStatus::PendingInit | AgentStatus::Shutdown => processor.dimmed, - AgentStatus::Running => processor.cyan, - AgentStatus::Interrupted => processor.yellow, - AgentStatus::Completed(_) => processor.green, - AgentStatus::Errored(_) | AgentStatus::NotFound => processor.red, - } -} - -fn is_collab_status_failure(status: &AgentStatus) -> bool { - matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound) -} - -fn format_receiver_list(ids: &[codex_protocol::ThreadId]) -> String { - if ids.is_empty() { - return "none".to_string(); - } - ids.iter() - .map(ToString::to_string) - .collect::>() - .join(", ") -} - -fn truncate_preview(text: &str, max_chars: usize) -> String { - if text.chars().count() <= max_chars { - return text.to_string(); - } - - let preview = text.chars().take(max_chars).collect::(); - format!("{preview}…") -} - -fn format_mcp_invocation(invocation: &McpInvocation) -> String { - // Build fully-qualified tool name: server.tool - let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool); - - // Format arguments as compact JSON so they fit on one line. - let args_str = invocation - .arguments - .as_ref() - .map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string())) - .unwrap_or_default(); - - if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - } +fn should_print_final_message_to_tty( + final_message: Option<&str>, + final_message_rendered: bool, + stdout_is_terminal: bool, + stderr_is_terminal: bool, +) -> bool { + final_message.is_some() && !final_message_rendered && stdout_is_terminal && stderr_is_terminal } #[cfg(test)] mod tests { - use std::path::PathBuf; - - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::HookCompletedEvent; - use codex_protocol::protocol::HookEventName; - use codex_protocol::protocol::HookExecutionMode; - use codex_protocol::protocol::HookHandlerType; - use codex_protocol::protocol::HookOutputEntry; - use codex_protocol::protocol::HookRunStatus; - use codex_protocol::protocol::HookRunSummary; - use codex_protocol::protocol::HookScope; - use codex_protocol::protocol::HookStartedEvent; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnStatus; + use owo_colors::Style; use super::EventProcessorWithHumanOutput; + use super::final_message_from_turn_items; + use super::reasoning_text; use super::should_print_final_message_to_stdout; - use pretty_assertions::assert_eq; + use super::should_print_final_message_to_tty; + use crate::event_processor::EventProcessor; + use codex_app_server_protocol::ServerNotification; #[test] fn suppresses_final_stdout_message_when_both_streams_are_terminals() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), true, true), - false - ); + assert!(!should_print_final_message_to_stdout( + Some("hello"), + true, + true + )); } #[test] fn prints_final_stdout_message_when_stdout_is_not_terminal() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), false, true), + assert!(should_print_final_message_to_stdout( + Some("hello"), + false, true - ); + )); } #[test] fn prints_final_stdout_message_when_stderr_is_not_terminal() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), true, false), - true - ); - } - - #[test] - fn does_not_print_when_message_is_missing() { - assert_eq!( - should_print_final_message_to_stdout(None, false, false), + assert!(should_print_final_message_to_stdout( + Some("hello"), + true, false + )); + } + + #[test] + fn suppresses_final_stdout_message_when_missing() { + assert!(!should_print_final_message_to_stdout(None, false, false)); + } + + #[test] + fn prints_final_tty_message_when_not_yet_rendered() { + assert!(should_print_final_message_to_tty( + Some("hello"), + false, + true, + true + )); + } + + #[test] + fn suppresses_final_tty_message_when_already_rendered() { + assert!(!should_print_final_message_to_tty( + Some("hello"), + true, + true, + true + )); + } + + #[test] + fn reasoning_text_prefers_summary_when_raw_reasoning_is_hidden() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ false, ); + + assert_eq!(text.as_deref(), Some("summary")); } #[test] - fn hook_started_with_status_message_is_not_silent() { - let event = HookStartedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run( - HookRunStatus::Running, - Some("running hook"), - Vec::new(), - HookEventName::Stop, - ), - }; + fn reasoning_text_uses_raw_content_when_enabled() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ true, + ); - assert!(!EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookStarted(event) - )); + assert_eq!(text.as_deref(), Some("raw")); } #[test] - fn hook_completed_failure_interrupts_progress() { - let event = HookCompletedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run(HookRunStatus::Failed, None, Vec::new(), HookEventName::Stop), - }; + fn final_message_from_turn_items_uses_latest_agent_message() { + let message = final_message_from_turn_items(&[ + ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "first".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "plan".to_string(), + }, + ThreadItem::AgentMessage { + id: "msg-2".to_string(), + text: "second".to_string(), + phase: None, + memory_citation: None, + }, + ]); - assert!(EventProcessorWithHumanOutput::should_interrupt_progress( - &EventMsg::HookCompleted(event) - )); + assert_eq!(message.as_deref(), Some("second")); } #[test] - fn hook_completed_success_without_entries_stays_silent() { - let event = HookCompletedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run( - HookRunStatus::Completed, - None, - Vec::new(), - HookEventName::Stop, - ), - }; + fn final_message_from_turn_items_falls_back_to_latest_plan() { + let message = final_message_from_turn_items(&[ + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["inspect".to_string()], + content: Vec::new(), + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "first plan".to_string(), + }, + ThreadItem::Plan { + id: "plan-2".to_string(), + text: "final plan".to_string(), + }, + ]); - assert!(EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookCompleted(event) - )); + assert_eq!(message.as_deref(), Some("final plan")); } - fn hook_run( - status: HookRunStatus, - status_message: Option<&str>, - entries: Vec, - event_name: HookEventName, - ) -> HookRunSummary { - HookRunSummary { - id: "hook-run-1".to_string(), - event_name, - handler_type: HookHandlerType::Command, - execution_mode: HookExecutionMode::Sync, - scope: HookScope::Turn, - source_path: PathBuf::from("/tmp/hooks.json"), - display_order: 0, + #[test] + fn turn_completed_recovers_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: None, + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( status, - status_message: status_message.map(ToOwned::to_owned), - started_at: 0, - completed_at: Some(1), - duration_ms: Some(1), - entries, - } + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); + } + + #[test] + fn turn_completed_overwrites_stale_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("stale answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); + assert!(!processor.final_message_rendered); + } + + #[test] + fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("streamed answer".to_string()), + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("streamed answer")); + assert!(processor.emit_final_message_on_shutdown); + } + + #[test] + fn turn_failed_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); + } + + #[test] + fn turn_interrupted_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Interrupted, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); } } diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 33f44add77..1a085d93d0 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -1,8 +1,24 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; -use crate::event_processor::CodexStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TurnStatus; +use codex_core::config::Config; +use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::SessionConfiguredEvent; +use serde_json::json; + +pub use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; use crate::exec_events::AgentMessageItem; @@ -12,7 +28,7 @@ use crate::exec_events::CollabTool; use crate::exec_events::CollabToolCallItem; use crate::exec_events::CollabToolCallStatus; use crate::exec_events::CommandExecutionItem; -use crate::exec_events::CommandExecutionStatus; +use crate::exec_events::CommandExecutionStatus as ExecCommandExecutionStatus; use crate::exec_events::ErrorItem; use crate::exec_events::FileChangeItem; use crate::exec_events::FileUpdateChange; @@ -22,13 +38,13 @@ use crate::exec_events::ItemUpdatedEvent; use crate::exec_events::McpToolCallItem; use crate::exec_events::McpToolCallItemError; use crate::exec_events::McpToolCallItemResult; -use crate::exec_events::McpToolCallStatus; -use crate::exec_events::PatchApplyStatus; -use crate::exec_events::PatchChangeKind; +use crate::exec_events::McpToolCallStatus as ExecMcpToolCallStatus; +use crate::exec_events::PatchApplyStatus as ExecPatchApplyStatus; +use crate::exec_events::PatchChangeKind as ExecPatchChangeKind; use crate::exec_events::ReasoningItem; use crate::exec_events::ThreadErrorEvent; use crate::exec_events::ThreadEvent; -use crate::exec_events::ThreadItem; +use crate::exec_events::ThreadItem as ExecThreadItem; use crate::exec_events::ThreadItemDetails; use crate::exec_events::ThreadStartedEvent; use crate::exec_events::TodoItem; @@ -38,45 +54,16 @@ use crate::exec_events::TurnFailedEvent; use crate::exec_events::TurnStartedEvent; use crate::exec_events::Usage; use crate::exec_events::WebSearchItem; -use codex_core::config::Config; -use codex_protocol::models::WebSearchAction; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol; -use codex_protocol::protocol::AgentStatus as CoreAgentStatus; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use serde_json::Value as JsonValue; -use tracing::error; -use tracing::warn; pub struct EventProcessorWithJsonOutput { last_message_path: Option, - last_proposed_plan: Option, - next_event_id: AtomicU64, - // Tracks running commands by call_id, including the associated item id. - running_commands: HashMap, - running_patch_applies: HashMap, - // Tracks the todo list for the current turn (at most one per turn). + next_item_id: AtomicU64, + raw_to_exec_item_id: HashMap, running_todo_list: Option, - last_total_token_usage: Option, - running_mcp_tool_calls: HashMap, - running_collab_tool_calls: HashMap, - running_web_search_calls: HashMap, + last_total_token_usage: Option, last_critical_error: Option, -} - -#[derive(Debug, Clone)] -struct RunningCommand { - command: String, - item_id: String, - aggregated_output: String, + final_message: Option, + emit_final_message_on_shutdown: bool, } #[derive(Debug, Clone)] @@ -85,800 +72,608 @@ struct RunningTodoList { items: Vec, } -#[derive(Debug, Clone)] -struct RunningMcpToolCall { - server: String, - tool: String, - item_id: String, - arguments: JsonValue, -} - -#[derive(Debug, Clone)] -struct RunningCollabToolCall { - tool: CollabTool, - item_id: String, +#[derive(Debug, PartialEq)] +pub struct CollectedThreadEvents { + pub events: Vec, + pub status: CodexStatus, } impl EventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { last_message_path, - last_proposed_plan: None, - next_event_id: AtomicU64::new(0), - running_commands: HashMap::new(), - running_patch_applies: HashMap::new(), + next_item_id: AtomicU64::new(0), + raw_to_exec_item_id: HashMap::new(), running_todo_list: None, last_total_token_usage: None, - running_mcp_tool_calls: HashMap::new(), - running_collab_tool_calls: HashMap::new(), - running_web_search_calls: HashMap::new(), last_critical_error: None, + final_message: None, + emit_final_message_on_shutdown: false, } } - pub fn collect_thread_events(&mut self, event: &protocol::Event) -> Vec { - match &event.msg { - protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), - protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(), - protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), - protocol::EventMsg::ItemCompleted(protocol::ItemCompletedEvent { - item: codex_protocol::items::TurnItem::Plan(item), - .. - }) => { - self.last_proposed_plan = Some(item.text.clone()); - Vec::new() - } - protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), - protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), - protocol::EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), - protocol::EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), - protocol::EventMsg::ExecCommandOutputDelta(ev) => { - self.handle_output_chunk(&ev.call_id, &ev.chunk) - } - protocol::EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), - protocol::EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), - protocol::EventMsg::CollabAgentSpawnBegin(ev) => self.handle_collab_spawn_begin(ev), - protocol::EventMsg::CollabAgentSpawnEnd(ev) => self.handle_collab_spawn_end(ev), - protocol::EventMsg::CollabAgentInteractionBegin(ev) => { - self.handle_collab_interaction_begin(ev) - } - protocol::EventMsg::CollabAgentInteractionEnd(ev) => { - self.handle_collab_interaction_end(ev) - } - protocol::EventMsg::CollabWaitingBegin(ev) => self.handle_collab_wait_begin(ev), - protocol::EventMsg::CollabWaitingEnd(ev) => self.handle_collab_wait_end(ev), - protocol::EventMsg::CollabCloseBegin(ev) => self.handle_collab_close_begin(ev), - protocol::EventMsg::CollabCloseEnd(ev) => self.handle_collab_close_end(ev), - protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), - protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), - protocol::EventMsg::WebSearchBegin(ev) => self.handle_web_search_begin(ev), - protocol::EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev), - protocol::EventMsg::TokenCount(ev) => { - if let Some(info) = &ev.info { - self.last_total_token_usage = Some(info.total_token_usage.clone()); - } - Vec::new() - } - protocol::EventMsg::TurnStarted(ev) => self.handle_task_started(ev), - protocol::EventMsg::TurnComplete(_) => self.handle_task_complete(), - protocol::EventMsg::Error(ev) => { - let error = ThreadErrorEvent { - message: ev.message.clone(), - }; - self.last_critical_error = Some(error.clone()); - vec![ThreadEvent::Error(error)] - } - protocol::EventMsg::Warning(ev) => { - let item = ThreadItem { - id: self.get_next_item_id(), - details: ThreadItemDetails::Error(ErrorItem { - message: ev.message.clone(), - }), - }; - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - protocol::EventMsg::StreamError(ev) => { - let message = match &ev.additional_details { - Some(details) if !details.trim().is_empty() => { - format!("{} ({})", ev.message, details) - } - _ => ev.message.clone(), - }; - vec![ThreadEvent::Error(ThreadErrorEvent { message })] - } - protocol::EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), - _ => Vec::new(), - } + pub fn final_message(&self) -> Option<&str> { + self.final_message.as_deref() } - fn get_next_item_id(&self) -> String { - format!( - "item_{}", - self.next_event_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) - ) + fn next_item_id(&self) -> String { + format!("item_{}", self.next_item_id.fetch_add(1, Ordering::SeqCst)) } - fn handle_session_configured( - &self, - payload: &protocol::SessionConfiguredEvent, - ) -> Vec { - vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: payload.session_id.to_string(), - })] - } - - fn handle_web_search_begin(&mut self, ev: &protocol::WebSearchBeginEvent) -> Vec { - if self.running_web_search_calls.contains_key(&ev.call_id) { - return Vec::new(); - } - let item_id = self.get_next_item_id(); - self.running_web_search_calls - .insert(ev.call_id.clone(), item_id.clone()); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: ev.call_id.clone(), - query: String::new(), - action: WebSearchAction::Other, - }), - }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - fn handle_web_search_end(&mut self, ev: &protocol::WebSearchEndEvent) -> Vec { - let item_id = self - .running_web_search_calls - .remove(&ev.call_id) - .unwrap_or_else(|| self.get_next_item_id()); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: ev.call_id.clone(), - query: ev.query.clone(), - action: ev.action.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { - //TODO see how we want to process them - vec![] - } - - fn handle_terminal_interaction( - &mut self, - _ev: &protocol::TerminalInteractionEvent, - ) -> Vec { - //TODO see how we want to process them - vec![] - } - - fn handle_agent_message(&self, payload: &protocol::AgentMessageEvent) -> Vec { - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::AgentMessage(AgentMessageItem { - text: payload.message.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_reasoning_event(&self, ev: &protocol::AgentReasoningEvent) -> Vec { - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::Reasoning(ReasoningItem { - text: ev.text.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - fn handle_exec_command_begin( - &mut self, - ev: &protocol::ExecCommandBeginEvent, - ) -> Vec { - let item_id = self.get_next_item_id(); - - let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) { - Ok(command_string) => command_string, - Err(e) => { - warn!( - call_id = ev.call_id, - "Failed to stringify command: {e:?}; skipping item.started" - ); - ev.command.join(" ") - } - }; - - self.running_commands.insert( - ev.call_id.clone(), - RunningCommand { - command: command_string.clone(), - item_id: item_id.clone(), - aggregated_output: String::new(), - }, + #[allow(clippy::print_stdout)] + fn emit(&self, event: ThreadEvent) { + println!( + "{}", + serde_json::to_string(&event).unwrap_or_else(|err| { + json!({ + "type": "error", + "message": format!("failed to serialize exec json event: {err}"), + }) + .to_string() + }) ); - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: command_string, - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] } - fn handle_mcp_tool_call_begin( - &mut self, - ev: &protocol::McpToolCallBeginEvent, - ) -> Vec { - let item_id = self.get_next_item_id(); - let server = ev.invocation.server.clone(); - let tool = ev.invocation.tool.clone(); - let arguments = ev.invocation.arguments.clone().unwrap_or(JsonValue::Null); - - self.running_mcp_tool_calls.insert( - ev.call_id.clone(), - RunningMcpToolCall { - server: server.clone(), - tool: tool.clone(), - item_id: item_id.clone(), - arguments: arguments.clone(), - }, - ); - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server, - tool, - arguments, - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + fn usage_from_last_total(&self) -> Usage { + let Some(usage) = self.last_total_token_usage.as_ref() else { + return Usage::default(); }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] + Usage { + input_tokens: usage.total.input_tokens, + cached_input_tokens: usage.total.cached_input_tokens, + output_tokens: usage.total.output_tokens, + } } - fn handle_mcp_tool_call_end(&mut self, ev: &protocol::McpToolCallEndEvent) -> Vec { - let status = if ev.is_success() { - McpToolCallStatus::Completed - } else { - McpToolCallStatus::Failed - }; - - let (server, tool, item_id, arguments) = - match self.running_mcp_tool_calls.remove(&ev.call_id) { - Some(running) => ( - running.server, - running.tool, - running.item_id, - running.arguments, + pub fn map_todo_items(plan: &[codex_app_server_protocol::TurnPlanStep]) -> Vec { + plan.iter() + .map(|step| TodoItem { + text: step.step.clone(), + completed: matches!( + step.status, + codex_app_server_protocol::TurnPlanStepStatus::Completed ), - None => { - warn!( - call_id = ev.call_id, - "Received McpToolCallEnd without begin; synthesizing new item" - ); - ( - ev.invocation.server.clone(), - ev.invocation.tool.clone(), - self.get_next_item_id(), - ev.invocation.arguments.clone().unwrap_or(JsonValue::Null), - ) + }) + .collect() + } + + fn map_item_with_id( + item: ThreadItem, + make_id: impl FnOnce() -> String, + ) -> Option { + match item { + ThreadItem::AgentMessage { text, .. } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { text }), + }), + ThreadItem::Reasoning { summary, .. } => { + let text = summary.join("\n"); + if text.trim().is_empty() { + return None; } - }; - - let (result, error) = match &ev.result { - Ok(value) => { - let result = McpToolCallItemResult { - content: value.content.clone(), - structured_content: value.structured_content.clone(), - }; - (Some(result), None) + Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::Reasoning(ReasoningItem { text }), + }) } - Err(message) => ( - None, - Some(McpToolCallItemError { - message: message.clone(), + ThreadItem::CommandExecution { + command, + aggregated_output, + exit_code, + status, + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command, + aggregated_output: aggregated_output.unwrap_or_default(), + exit_code, + status: match status { + CommandExecutionStatus::InProgress => ExecCommandExecutionStatus::InProgress, + CommandExecutionStatus::Completed => ExecCommandExecutionStatus::Completed, + CommandExecutionStatus::Failed => ExecCommandExecutionStatus::Failed, + CommandExecutionStatus::Declined => ExecCommandExecutionStatus::Declined, + }, }), - ), - }; - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::McpToolCall(McpToolCallItem { + }), + ThreadItem::FileChange { + changes, status, .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: changes + .into_iter() + .map(|change| FileUpdateChange { + path: change.path, + kind: match change.kind { + PatchChangeKind::Add => ExecPatchChangeKind::Add, + PatchChangeKind::Delete => ExecPatchChangeKind::Delete, + PatchChangeKind::Update { .. } => ExecPatchChangeKind::Update, + }, + }) + .collect(), + status: match status { + PatchApplyStatus::InProgress => ExecPatchApplyStatus::InProgress, + PatchApplyStatus::Completed => ExecPatchApplyStatus::Completed, + PatchApplyStatus::Failed | PatchApplyStatus::Declined => { + ExecPatchApplyStatus::Failed + } + }, + }), + }), + ThreadItem::McpToolCall { server, tool, + status, arguments, result, error, - status, + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server, + tool, + status: match status { + McpToolCallStatus::InProgress => ExecMcpToolCallStatus::InProgress, + McpToolCallStatus::Completed => ExecMcpToolCallStatus::Completed, + McpToolCallStatus::Failed => ExecMcpToolCallStatus::Failed, + }, + arguments, + result: result.map(|result| McpToolCallItemResult { + content: result.content, + structured_content: result.structured_content, + }), + error: error.map(|error| McpToolCallItemError { + message: error.message, + }), + }), }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_collab_spawn_begin(&mut self, ev: &CollabAgentSpawnBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::SpawnAgent, - ev.sender_thread_id.to_string(), - Vec::new(), - Some(ev.prompt.clone()), - ) - } - - fn handle_collab_spawn_end(&mut self, ev: &CollabAgentSpawnEndEvent) -> Vec { - let (receiver_thread_ids, agents_states) = match ev.new_thread_id { - Some(id) => { - let receiver_id = id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - ( - vec![receiver_id.clone()], - [(receiver_id, agent_state)].into_iter().collect(), - ) - } - None => (Vec::new(), HashMap::new()), - }; - let status = if ev.new_thread_id.is_some() && !is_collab_failure(&ev.status) { - CollabToolCallStatus::Completed - } else { - CollabToolCallStatus::Failed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::SpawnAgent, - ev.sender_thread_id.to_string(), - receiver_thread_ids, - Some(ev.prompt.clone()), - agents_states, - status, - ) - } - - fn handle_collab_interaction_begin( - &mut self, - ev: &CollabAgentInteractionBeginEvent, - ) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::SendInput, - ev.sender_thread_id.to_string(), - vec![ev.receiver_thread_id.to_string()], - Some(ev.prompt.clone()), - ) - } - - fn handle_collab_interaction_end( - &mut self, - ev: &CollabAgentInteractionEndEvent, - ) -> Vec { - let receiver_id = ev.receiver_thread_id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - let status = if is_collab_failure(&ev.status) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::SendInput, - ev.sender_thread_id.to_string(), - vec![receiver_id.clone()], - Some(ev.prompt.clone()), - [(receiver_id, agent_state)].into_iter().collect(), - status, - ) - } - - fn handle_collab_wait_begin(&mut self, ev: &CollabWaitingBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::Wait, - ev.sender_thread_id.to_string(), - ev.receiver_thread_ids - .iter() - .map(ToString::to_string) - .collect(), - /*prompt*/ None, - ) - } - - fn handle_collab_wait_end(&mut self, ev: &CollabWaitingEndEvent) -> Vec { - let status = if ev.statuses.values().any(is_collab_failure) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - let mut receiver_thread_ids = ev - .statuses - .keys() - .map(ToString::to_string) - .collect::>(); - receiver_thread_ids.sort(); - let agents_states = ev - .statuses - .iter() - .map(|(thread_id, status)| { - ( - thread_id.to_string(), - CollabAgentState::from(status.clone()), - ) - }) - .collect(); - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::Wait, - ev.sender_thread_id.to_string(), - receiver_thread_ids, - /*prompt*/ None, - agents_states, - status, - ) - } - - fn handle_collab_close_begin(&mut self, ev: &CollabCloseBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::CloseAgent, - ev.sender_thread_id.to_string(), - vec![ev.receiver_thread_id.to_string()], - /*prompt*/ None, - ) - } - - fn handle_collab_close_end(&mut self, ev: &CollabCloseEndEvent) -> Vec { - let receiver_id = ev.receiver_thread_id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - let status = if is_collab_failure(&ev.status) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::CloseAgent, - ev.sender_thread_id.to_string(), - vec![receiver_id.clone()], - /*prompt*/ None, - [(receiver_id, agent_state)].into_iter().collect(), - status, - ) - } - - fn start_collab_tool_call( - &mut self, - call_id: &str, - tool: CollabTool, - sender_thread_id: String, - receiver_thread_ids: Vec, - prompt: Option, - ) -> Vec { - let item_id = self.get_next_item_id(); - self.running_collab_tool_calls.insert( - call_id.to_string(), - RunningCollabToolCall { - tool: tool.clone(), - item_id: item_id.clone(), - }, - ); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool, - sender_thread_id, - receiver_thread_ids, - prompt, - agents_states: HashMap::new(), - status: CollabToolCallStatus::InProgress, - }), - }; - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - #[allow(clippy::too_many_arguments)] - fn finish_collab_tool_call( - &mut self, - call_id: &str, - tool: CollabTool, - sender_thread_id: String, - receiver_thread_ids: Vec, - prompt: Option, - agents_states: HashMap, - status: CollabToolCallStatus, - ) -> Vec { - let (tool, item_id) = match self.running_collab_tool_calls.remove(call_id) { - Some(running) => (running.tool, running.item_id), - None => { - warn!( - call_id, - "Received collab tool end without begin; synthesizing new item" - ); - (tool, self.get_next_item_id()) - } - }; - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + ThreadItem::CollabAgentToolCall { tool, sender_thread_id, receiver_thread_ids, prompt, agents_states, status, - }), - }; - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_patch_apply_begin( - &mut self, - ev: &protocol::PatchApplyBeginEvent, - ) -> Vec { - self.running_patch_applies - .insert(ev.call_id.clone(), ev.clone()); - - Vec::new() - } - - fn map_change_kind(&self, kind: &protocol::FileChange) -> PatchChangeKind { - match kind { - protocol::FileChange::Add { .. } => PatchChangeKind::Add, - protocol::FileChange::Delete { .. } => PatchChangeKind::Delete, - protocol::FileChange::Update { .. } => PatchChangeKind::Update, - } - } - - fn handle_patch_apply_end(&mut self, ev: &protocol::PatchApplyEndEvent) -> Vec { - if let Some(running_patch_apply) = self.running_patch_applies.remove(&ev.call_id) { - let status = if ev.success { - PatchApplyStatus::Completed - } else { - PatchApplyStatus::Failed - }; - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::FileChange(FileChangeItem { - changes: running_patch_apply - .changes - .iter() - .map(|(path, change)| FileUpdateChange { - path: path.to_str().unwrap_or("").to_string(), - kind: self.map_change_kind(change), + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: match tool { + CollabAgentTool::SpawnAgent => CollabTool::SpawnAgent, + CollabAgentTool::SendInput => CollabTool::SendInput, + CollabAgentTool::ResumeAgent => CollabTool::Wait, + CollabAgentTool::Wait => CollabTool::Wait, + CollabAgentTool::CloseAgent => CollabTool::CloseAgent, + }, + sender_thread_id, + receiver_thread_ids, + prompt, + agents_states: agents_states + .into_iter() + .map(|(thread_id, state)| { + ( + thread_id, + CollabAgentState { + status: match state.status { + codex_app_server_protocol::CollabAgentStatus::PendingInit => { + CollabAgentStatus::PendingInit + } + codex_app_server_protocol::CollabAgentStatus::Running => { + CollabAgentStatus::Running + } + codex_app_server_protocol::CollabAgentStatus::Interrupted => { + CollabAgentStatus::Interrupted + } + codex_app_server_protocol::CollabAgentStatus::Completed => { + CollabAgentStatus::Completed + } + codex_app_server_protocol::CollabAgentStatus::Errored => { + CollabAgentStatus::Errored + } + codex_app_server_protocol::CollabAgentStatus::Shutdown => { + CollabAgentStatus::Shutdown + } + codex_app_server_protocol::CollabAgentStatus::NotFound => { + CollabAgentStatus::NotFound + } + }, + message: state.message, + }, + ) }) .collect(), - status, + status: match status { + CollabAgentToolCallStatus::InProgress => CollabToolCallStatus::InProgress, + CollabAgentToolCallStatus::Completed => CollabToolCallStatus::Completed, + CollabAgentToolCallStatus::Failed => CollabToolCallStatus::Failed, + }, }), - }; - - return vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]; - } - - Vec::new() - } - - fn handle_exec_command_end(&mut self, ev: &protocol::ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { - command, - item_id, - aggregated_output, - }) = self.running_commands.remove(&ev.call_id) - else { - warn!( - call_id = ev.call_id, - "ExecCommandEnd without matching ExecCommandBegin; skipping item.completed" - ); - return Vec::new(); - }; - let status = if ev.exit_code == 0 { - CommandExecutionStatus::Completed - } else { - CommandExecutionStatus::Failed - }; - let aggregated_output = if ev.aggregated_output.is_empty() { - aggregated_output - } else { - ev.aggregated_output.clone() - }; - let item = ThreadItem { - id: item_id, - - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command, - aggregated_output, - exit_code: Some(ev.exit_code), - status, }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + ThreadItem::WebSearch { + id: raw_id, + query, + action, + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: raw_id, + query, + action: match action { + Some(action) => serde_json::from_value( + serde_json::to_value(action).unwrap_or_else(|_| json!("other")), + ) + .unwrap_or(WebSearchAction::Other), + None => WebSearchAction::Other, + }, + }), + }), + _ => None, + } } - fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec { - args.plan + fn started_item_id(&mut self, raw_id: &str) -> String { + if let Some(existing) = self.raw_to_exec_item_id.get(raw_id) { + return existing.clone(); + } + let exec_id = self.next_item_id(); + self.raw_to_exec_item_id + .insert(raw_id.to_string(), exec_id.clone()); + exec_id + } + + fn completed_item_id(&mut self, raw_id: &str) -> String { + self.raw_to_exec_item_id + .remove(raw_id) + .unwrap_or_else(|| self.next_item_id()) + } + + fn map_started_item(&mut self, item: ThreadItem) -> Option { + match item { + ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => None, + other => { + let raw_id = other.id().to_string(); + Self::map_item_with_id(other, || self.started_item_id(&raw_id)) + } + } + } + + fn map_completed_item_mut(&mut self, item: ThreadItem) -> Option { + if let ThreadItem::Reasoning { summary, .. } = &item + && summary.join("\n").trim().is_empty() + { + return None; + } + match &item { + ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => { + Self::map_item_with_id(item, || self.next_item_id()) + } + other => { + let raw_id = other.id().to_string(); + Self::map_item_with_id(item, || self.completed_item_id(&raw_id)) + } + } + } + + fn reconcile_unfinished_started_items( + &mut self, + turn_items: &[ThreadItem], + ) -> Vec { + turn_items .iter() - .map(|p| TodoItem { - text: p.step.clone(), - completed: matches!(p.status, StepStatus::Completed), + .filter_map(|item| { + let raw_id = item.id().to_string(); + if !self.raw_to_exec_item_id.contains_key(&raw_id) { + return None; + } + self.map_completed_item_mut(item.clone()) + .map(|item| ThreadEvent::ItemCompleted(ItemCompletedEvent { item })) }) .collect() } - fn handle_plan_update(&mut self, args: &UpdatePlanArgs) -> Vec { - let items = self.todo_items_from_plan(args); - - if let Some(running) = &mut self.running_todo_list { - running.items = items.clone(); - let item = ThreadItem { - id: running.item_id.clone(), - details: ThreadItemDetails::TodoList(TodoListItem { items }), - }; - return vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { item })]; - } - - let item_id = self.get_next_item_id(); - self.running_todo_list = Some(RunningTodoList { - item_id: item_id.clone(), - items: items.clone(), - }); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::TodoList(TodoListItem { items }), - }; - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - fn handle_task_started(&mut self, _: &protocol::TurnStartedEvent) -> Vec { - self.last_critical_error = None; - vec![ThreadEvent::TurnStarted(TurnStartedEvent {})] - } - - fn handle_task_complete(&mut self) -> Vec { - let usage = if let Some(u) = &self.last_total_token_usage { - Usage { - input_tokens: u.input_tokens, - cached_input_tokens: u.cached_input_tokens, - output_tokens: u.output_tokens, - } - } else { - Usage::default() - }; - - let mut items = Vec::new(); - - if let Some(running) = self.running_todo_list.take() { - let item = ThreadItem { - id: running.item_id, - details: ThreadItemDetails::TodoList(TodoListItem { - items: running.items, - }), - }; - items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); - } - - if !self.running_commands.is_empty() { - for (_, running) in self.running_commands.drain() { - let item = ThreadItem { - id: running.item_id, - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: running.command, - aggregated_output: running.aggregated_output, - exit_code: None, - status: CommandExecutionStatus::Completed, - }), - }; - items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); - } - } - - if let Some(error) = self.last_critical_error.take() { - items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); - } else { - items.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { usage })); - } - + fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { items + .iter() + .rev() + .find_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) + .or_else(|| { + items.iter().rev().find_map(|item| match item { + ThreadItem::Plan { text, .. } => Some(text.clone()), + _ => None, + }) + }) } -} -fn is_collab_failure(status: &CoreAgentStatus) -> bool { - matches!( - status, - CoreAgentStatus::Errored(_) | CoreAgentStatus::NotFound - ) -} + pub fn thread_started_event(session_configured: &SessionConfiguredEvent) -> ThreadEvent { + ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: session_configured.session_id.to_string(), + }) + } -impl From for CollabAgentState { - fn from(value: CoreAgentStatus) -> Self { - match value { - CoreAgentStatus::PendingInit => Self { - status: CollabAgentStatus::PendingInit, - message: None, - }, - CoreAgentStatus::Running => Self { - status: CollabAgentStatus::Running, - message: None, - }, - CoreAgentStatus::Interrupted => Self { - status: CollabAgentStatus::Interrupted, - message: None, - }, - CoreAgentStatus::Completed(message) => Self { - status: CollabAgentStatus::Completed, - message, - }, - CoreAgentStatus::Errored(message) => Self { - status: CollabAgentStatus::Errored, - message: Some(message), - }, - CoreAgentStatus::Shutdown => Self { - status: CollabAgentStatus::Shutdown, - message: None, - }, - CoreAgentStatus::NotFound => Self { - status: CollabAgentStatus::NotFound, - message: None, - }, + pub fn collect_warning(&mut self, message: String) -> CollectedThreadEvents { + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })], + status: CodexStatus::Running, } } + + pub fn collect_thread_events( + &mut self, + notification: ServerNotification, + ) -> CollectedThreadEvents { + let mut events = Vec::new(); + let status = match notification { + ServerNotification::ConfigWarning(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + ServerNotification::Error(notification) => { + let message = match notification.error.additional_details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.error.message) + } + _ => notification.error.message, + }; + let error = ThreadErrorEvent { message }; + self.last_critical_error = Some(error.clone()); + events.push(ThreadEvent::Error(error)); + CodexStatus::Running + } + ServerNotification::DeprecationNotice(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + ServerNotification::HookStarted(_) | ServerNotification::HookCompleted(_) => { + CodexStatus::Running + } + ServerNotification::ItemStarted(notification) => { + if let Some(item) = self.map_started_item(notification.item) { + events.push(ThreadEvent::ItemStarted(ItemStartedEvent { item })); + } + CodexStatus::Running + } + ServerNotification::ItemCompleted(notification) => { + if let Some(item) = self.map_completed_item_mut(notification.item) { + if let ThreadItemDetails::AgentMessage(AgentMessageItem { text }) = + &item.details + { + self.final_message = Some(text.clone()); + } + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + CodexStatus::Running + } + ServerNotification::ModelRerouted(notification) => { + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { + message: format!( + "model rerouted: {} -> {} ({:?})", + notification.from_model, notification.to_model, notification.reason + ), + }), + }, + })); + CodexStatus::Running + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + ServerNotification::TurnCompleted(notification) => { + if let Some(running) = self.running_todo_list.take() { + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: running.item_id, + details: ThreadItemDetails::TodoList(TodoListItem { + items: running.items, + }), + }, + })); + } + events.extend(self.reconcile_unfinished_started_items(¬ification.turn.items)); + match notification.turn.status { + TurnStatus::Completed => { + if let Some(final_message) = + Self::final_message_from_turn_items(notification.turn.items.as_slice()) + { + self.final_message = Some(final_message); + } + self.emit_final_message_on_shutdown = true; + events.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: self.usage_from_last_total(), + })); + CodexStatus::InitiateShutdown + } + TurnStatus::Failed => { + self.final_message = None; + self.emit_final_message_on_shutdown = false; + let error = notification + .turn + .error + .map(|error| ThreadErrorEvent { + message: match error.additional_details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", error.message) + } + _ => error.message, + }, + }) + .or_else(|| self.last_critical_error.clone()) + .unwrap_or_else(|| ThreadErrorEvent { + message: "turn failed".to_string(), + }); + events.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); + CodexStatus::InitiateShutdown + } + TurnStatus::Interrupted => { + self.final_message = None; + self.emit_final_message_on_shutdown = false; + CodexStatus::InitiateShutdown + } + TurnStatus::InProgress => CodexStatus::Running, + } + } + ServerNotification::TurnDiffUpdated(_) => CodexStatus::Running, + ServerNotification::TurnPlanUpdated(notification) => { + let items = Self::map_todo_items(¬ification.plan); + if let Some(running) = self.running_todo_list.as_mut() { + running.items = items.clone(); + let item_id = running.item_id.clone(); + events.push(ThreadEvent::ItemUpdated(ItemUpdatedEvent { + item: ExecThreadItem { + id: item_id, + details: ThreadItemDetails::TodoList(TodoListItem { items }), + }, + })); + } else { + let item_id = self.next_item_id(); + self.running_todo_list = Some(RunningTodoList { + item_id: item_id.clone(), + items: items.clone(), + }); + events.push(ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: item_id, + details: ThreadItemDetails::TodoList(TodoListItem { items }), + }, + })); + } + CodexStatus::Running + } + ServerNotification::TurnStarted(_) => { + events.push(ThreadEvent::TurnStarted(TurnStartedEvent {})); + CodexStatus::Running + } + _ => CodexStatus::Running, + }; + + CollectedThreadEvents { events, status } + } } impl EventProcessor for EventProcessorWithJsonOutput { - fn print_config_summary(&mut self, _: &Config, _: &str, ev: &protocol::SessionConfiguredEvent) { - self.process_event(protocol::Event { - id: "".to_string(), - msg: protocol::EventMsg::SessionConfigured(ev.clone()), - }); + fn print_config_summary( + &mut self, + _: &Config, + _: &str, + session_configured: &SessionConfiguredEvent, + ) { + self.emit(Self::thread_started_event(session_configured)); } - #[allow(clippy::print_stdout)] - fn process_event(&mut self, event: protocol::Event) -> CodexStatus { - let aggregated = self.collect_thread_events(&event); - for conv_event in aggregated { - match serde_json::to_string(&conv_event) { - Ok(line) => { - println!("{line}"); - } - Err(e) => { - error!("Failed to serialize event: {e:?}"); - } - } + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus { + let collected = self.collect_thread_events(notification); + for event in collected.events { + self.emit(event); } + collected.status + } - let protocol::Event { msg, .. } = event; + fn process_warning(&mut self, message: String) -> CodexStatus { + let collected = self.collect_warning(message); + for event in collected.events { + self.emit(event); + } + collected.status + } - match msg { - protocol::EventMsg::TurnComplete(protocol::TurnCompleteEvent { - last_agent_message, - .. - }) => { - if let Some(output_file) = self.last_message_path.as_deref() { - let last_message = last_agent_message - .as_deref() - .or(self.last_proposed_plan.as_deref()); - handle_last_message(last_message, output_file); - } - CodexStatus::InitiateShutdown - } - protocol::EventMsg::TurnAborted(_) => CodexStatus::InitiateShutdown, - protocol::EventMsg::ShutdownComplete => CodexStatus::Shutdown, - _ => CodexStatus::Running, + fn print_final_output(&mut self) { + if self.emit_final_message_on_shutdown + && let Some(path) = self.last_message_path.as_deref() + { + handle_last_message(self.final_message.as_deref(), path); } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn failed_turn_does_not_overwrite_output_last_message_file() { + let tempdir = tempdir().expect("create tempdir"); + let output_path = tempdir.path().join("last-message.txt"); + std::fs::write(&output_path, "keep existing contents").expect("seed output file"); + + let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone())); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "partial answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(processor.final_message(), Some("partial answer")); + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(codex_app_server_protocol::TurnError { + message: "turn failed".to_string(), + additional_details: None, + codex_error_info: None, + }), + }, + }, + )); + + assert_eq!(status, CodexStatus::InitiateShutdown); + assert_eq!(processor.final_message(), None); + + EventProcessor::print_final_output(&mut processor); + + assert_eq!( + std::fs::read_to_string(&output_path).expect("read output file"), + "keep existing contents" + ); + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c10b86f696..29ae7bf256 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -17,11 +17,9 @@ use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_client::InProcessServerEvent; -use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::RequestId; @@ -30,8 +28,16 @@ use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::Thread as AppServerThread; +use codex_app_server_protocol::ThreadItem as AppServerThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; @@ -40,9 +46,9 @@ use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_requirements::cloud_requirements_loader; -use codex_core::AuthManager; +use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::auth::AuthConfig; @@ -59,16 +65,17 @@ use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::format_config_error_with_source; use codex_core::format_exec_policy_error_with_source; use codex_core::git_info::get_git_repo_root; +use codex_core::path_utils; use codex_feedback::CodexFeedback; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; -use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -79,10 +86,9 @@ use event_processor_with_human_output::EventProcessorWithHumanOutput; use event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use serde_json::Value; use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::VecDeque; use std::io::IsTerminal; use std::io::Read; +use std::path::Path; use std::path::PathBuf; use supports_color::Stream; use tokio::sync::mpsc; @@ -101,8 +107,6 @@ use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::default_client::set_default_originator; -use codex_core::find_thread_path_by_id_str; -use codex_core::find_thread_path_by_name_str; const DEFAULT_ANALYTICS_ENABLED: bool = true; @@ -136,7 +140,6 @@ struct ExecRunArgs { in_process_start_args: InProcessClientStartArgs, command: Option, config: Config, - cursor_ansi: bool, dangerously_bypass_approvals_and_sandbox: bool, exec_span: tracing::Span, images: Vec, @@ -184,7 +187,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result prompt, output_schema: output_schema_path, config_overrides, - progress_cursor, } = cli; let (_stdout_with_ansi, stderr_with_ansi) = match color { @@ -195,25 +197,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result supports_color::on_cached(Stream::Stderr).is_some(), ), }; - let cursor_ansi = if progress_cursor { - true - } else { - match color { - cli::Color::Never => false, - cli::Color::Always => true, - cli::Color::Auto => { - if stderr_with_ansi || std::io::stderr().is_terminal() { - true - } else { - match std::env::var("TERM") { - Ok(term) => !term.is_empty() && term != "dumb", - Err(_) => false, - } - } - } - } - }; - // Build fmt layer (existing logging) to compose with OTEL layer. let default_level = "error"; @@ -287,18 +270,17 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } }; - let cloud_auth_manager = AuthManager::shared( - codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), - ); let chatgpt_base_url = config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = - cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url, codex_home.clone()); + let cloud_requirements = cloud_requirements_loader_for_storage( + codex_home.clone(), + /*enable_codex_api_key_env*/ false, + config_toml.cli_auth_credentials_store.unwrap_or_default(), + chatgpt_base_url, + ); let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = LoaderOverrides::default(); let run_cloud_requirements = cloud_requirements.clone(); @@ -455,7 +437,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result in_process_start_args, command, config, - cursor_ansi, dangerously_bypass_approvals_and_sandbox, exec_span: exec_span.clone(), images, @@ -477,7 +458,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { in_process_start_args, command, config, - cursor_ansi, dangerously_bypass_approvals_and_sandbox, exec_span, images, @@ -495,19 +475,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())), _ => Box::new(EventProcessorWithHumanOutput::create_with_ansi( stderr_with_ansi, - cursor_ansi, &config, last_message_file.clone(), )), }; - let required_mcp_servers: HashSet = config - .mcp_servers - .get() - .iter() - .filter(|(_, server)| server.enabled && server.required) - .map(|(name, _)| name.clone()) - .collect(); - if oss { // We're in the oss section, so provider_id should be Some // Let's handle None case gracefully though just in case @@ -547,17 +518,16 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { anyhow::anyhow!("failed to initialize in-process app-server client: {err}") })?; - // Handle resume subcommand by resolving a rollout path and using explicit resume API. + // Handle resume subcommand through existing `thread/list` + `thread/resume` + // APIs so exec no longer reaches into rollout storage directly. let (primary_thread_id, fallback_session_configured) = if let Some(ExecCommand::Resume(args)) = command.as_ref() { - let resume_path = resolve_resume_path(&config, args).await?; - - if let Some(path) = resume_path { + if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { let response: ThreadResumeResponse = send_request_with_response( &client, ClientRequest::ThreadResume { request_id: request_ids.next(), - params: thread_resume_params_from_config(&config, Some(path)), + params: thread_resume_params_from_config(&config, thread_id), }, "thread/resume", ) @@ -598,7 +568,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { }; let primary_thread_id_for_span = primary_thread_id.to_string(); - let mut buffered_events = VecDeque::new(); // Use the start/resume response as the authoritative bootstrap payload. // Waiting for a later streamed `SessionConfigured` event adds up to 10s of // avoidable startup latency on the in-process path. @@ -670,10 +639,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // is using. event_processor.print_config_summary(&config, &prompt_summary, &session_configured); if !json_mode && let Some(message) = codex_core::config::system_bwrap_warning() { - let _ = event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), - }); + event_processor.process_warning(message); } info!("Codex initialized with event: {session_configured:?}"); @@ -734,6 +700,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; + let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( + TurnStartedNotification { + thread_id: response.review_thread_id.clone(), + turn: response.turn.clone(), + }, + )); let task_id = response.turn.id; info!("Sent review request with event ID: {task_id}"); task_id @@ -748,34 +720,30 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let mut interrupt_channel_open = true; let primary_thread_id_for_requests = primary_thread_id.to_string(); loop { - let server_event = if let Some(event) = buffered_events.pop_front() { - Some(event) - } else { - tokio::select! { - maybe_interrupt = interrupt_rx.recv(), if interrupt_channel_open => { - if maybe_interrupt.is_none() { - interrupt_channel_open = false; - continue; - } - if let Err(err) = send_request_with_response::( - &client, - ClientRequest::TurnInterrupt { - request_id: request_ids.next(), - params: TurnInterruptParams { - thread_id: primary_thread_id_for_requests.clone(), - turn_id: task_id.clone(), - }, - }, - "turn/interrupt", - ) - .await - { - warn!("turn/interrupt failed: {err}"); - } + let server_event = tokio::select! { + maybe_interrupt = interrupt_rx.recv(), if interrupt_channel_open => { + if maybe_interrupt.is_none() { + interrupt_channel_open = false; continue; } - maybe_event = client.next_event() => maybe_event, + if let Err(err) = send_request_with_response::( + &client, + ClientRequest::TurnInterrupt { + request_id: request_ids.next(), + params: TurnInterruptParams { + thread_id: primary_thread_id_for_requests.clone(), + turn_id: task_id.clone(), + }, + }, + "turn/interrupt", + ) + .await + { + warn!("turn/interrupt failed: {err}"); + } + continue; } + maybe_event = client.next_event() => maybe_event, }; let Some(server_event) = server_event else { @@ -784,69 +752,39 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { match server_event { InProcessServerEvent::ServerRequest(request) => { - handle_server_request( - &client, - request, - &config, - &primary_thread_id_for_requests, - &mut error_seen, - ) - .await; + handle_server_request(&client, request, &mut error_seen).await; } - InProcessServerEvent::ServerNotification(notification) => { - if let ServerNotification::Error(payload) = ¬ification + InProcessServerEvent::ServerNotification(mut notification) => { + if let ServerNotification::Error(payload) = ¬ification { + if payload.thread_id == primary_thread_id_for_requests + && payload.turn_id == task_id + && !payload.will_retry + { + error_seen = true; + } + } else if let ServerNotification::TurnCompleted(payload) = ¬ification && payload.thread_id == primary_thread_id_for_requests - && payload.turn_id == task_id - && !payload.will_retry + && payload.turn.id == task_id + && matches!( + payload.turn.status, + codex_app_server_protocol::TurnStatus::Failed + | codex_app_server_protocol::TurnStatus::Interrupted + ) { error_seen = true; } - } - InProcessServerEvent::LegacyNotification(notification) => { - let decoded = match decode_legacy_notification(notification) { - Ok(event) => event, - Err(err) => { - warn!("{err}"); - continue; - } - }; - if decoded.conversation_id.as_deref() - != Some(primary_thread_id_for_requests.as_str()) - && decoded.conversation_id.is_some() - { - continue; - } - let event = decoded.event; - if matches!(event.msg, EventMsg::SessionConfigured(_)) { - continue; - } - if matches!(event.msg, EventMsg::Error(_)) { - // The legacy bridge still carries fatal turn failures for - // exec. Preserve the non-zero exit behavior until this - // path is fully replaced by typed server notifications. - error_seen = true; - } - match &event.msg { - EventMsg::TurnComplete(payload) => { - if payload.turn_id != task_id { - continue; - } - } - EventMsg::TurnAborted(payload) => { - if payload.turn_id.as_deref() != Some(task_id.as_str()) { - continue; - } - } - EventMsg::McpStartupUpdate(update) => { - if required_mcp_servers.contains(&update.server) - && let codex_protocol::protocol::McpStartupStatus::Failed { error } = - &update.status - { - error_seen = true; - eprintln!( - "Required MCP server '{}' failed to initialize: {error}", - update.server - ); + + maybe_backfill_turn_completed_items(&client, &mut request_ids, &mut notification) + .await; + + if should_process_notification( + ¬ification, + &primary_thread_id_for_requests, + &task_id, + ) { + match event_processor.process_server_notification(notification) { + CodexStatus::Running => {} + CodexStatus::InitiateShutdown => { if let Err(err) = request_shutdown( &client, &mut request_ids, @@ -859,37 +797,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { break; } } - _ => {} - } - - match event_processor.process_event(event) { - CodexStatus::Running => {} - CodexStatus::InitiateShutdown => { - if let Err(err) = request_shutdown( - &client, - &mut request_ids, - &primary_thread_id_for_requests, - ) - .await - { - warn!("thread/unsubscribe failed during shutdown: {err}"); - } - break; - } - CodexStatus::Shutdown => { - // `ShutdownComplete` does not identify which attached - // thread emitted it, so subagent shutdowns must not end - // the primary exec loop early. - } } } InProcessServerEvent::Lagged { skipped } => { let message = lagged_event_warning_message(skipped); warn!("{message}"); - let _ = event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), - }); + event_processor.process_warning(message); } } } @@ -936,10 +849,9 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { } } -fn thread_resume_params_from_config(config: &Config, path: Option) -> ThreadResumeParams { +fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { ThreadResumeParams { - thread_id: "resume".to_string(), - path, + thread_id, model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), @@ -1017,20 +929,19 @@ fn session_configured_from_thread_resume_response( ) } +fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { + match target { + ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, + ReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, + ReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, + ReviewTarget::Custom { instructions } => ApiReviewTarget::Custom { instructions }, + } +} + #[expect( clippy::too_many_arguments, reason = "session mapping keeps explicit fields" )] -/// Synthesizes startup session metadata from `thread/start` or `thread/resume`. -/// -/// This is a compatibility bridge for the current in-process architecture. -/// Some session fields are not available synchronously from the start/resume -/// response, so callers must treat the result as a best-effort fallback until -/// a later `SessionConfigured` event proves otherwise. -/// TODO(architecture): stop synthesizing a partial `SessionConfiguredEvent` -/// here. Either return the authoritative session-configured payload from -/// `thread/start`/`thread/resume`, or introduce a smaller bootstrap type for -/// exec so this path cannot accidentally depend on placeholder fields. fn session_configured_from_thread_response( thread_id: &str, thread_name: Option, @@ -1040,7 +951,7 @@ fn session_configured_from_thread_response( service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - sandbox_policy: codex_protocol::protocol::SandboxPolicy, + sandbox_policy: SandboxPolicy, cwd: PathBuf, reasoning_effort: Option, ) -> Result { @@ -1067,71 +978,261 @@ fn session_configured_from_thread_response( }) } -fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { - match target { - ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, - ReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, - ReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, - ReviewTarget::Custom { instructions } => ApiReviewTarget::Custom { instructions }, - } -} - -fn normalize_legacy_notification_method(method: &str) -> &str { - method.strip_prefix("codex/event/").unwrap_or(method) -} - fn lagged_event_warning_message(skipped: usize) -> String { format!("in-process app-server event stream lagged; dropped {skipped} events") } -struct DecodedLegacyNotification { - conversation_id: Option, - event: Event, +fn should_process_notification( + notification: &ServerNotification, + thread_id: &str, + turn_id: &str, +) -> bool { + match notification { + ServerNotification::ConfigWarning(_) | ServerNotification::DeprecationNotice(_) => true, + ServerNotification::Error(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::HookCompleted(notification) => { + notification.thread_id == thread_id + && notification + .turn_id + .as_deref() + .is_none_or(|candidate| candidate == turn_id) + } + ServerNotification::HookStarted(notification) => { + notification.thread_id == thread_id + && notification + .turn_id + .as_deref() + .is_none_or(|candidate| candidate == turn_id) + } + ServerNotification::ItemCompleted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ItemStarted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ModelRerouted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnCompleted(notification) => { + notification.thread_id == thread_id && notification.turn.id == turn_id + } + ServerNotification::TurnDiffUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnPlanUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnStarted(notification) => { + notification.thread_id == thread_id && notification.turn.id == turn_id + } + _ => false, + } } -fn decode_legacy_notification( - notification: JSONRPCNotification, -) -> Result { - let value = notification - .params - .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - let method = notification.method; - let normalized_method = normalize_legacy_notification_method(&method).to_string(); - let serde_json::Value::Object(mut object) = value else { - return Err(format!( - "legacy notification `{method}` params were not an object" - )); +async fn maybe_backfill_turn_completed_items( + client: &InProcessAppServerClient, + request_ids: &mut RequestIdSequencer, + notification: &mut ServerNotification, +) { + // In-process delivery may drop non-terminal item notifications under backpressure while still + // guaranteeing `turn/completed`. Because app-server currently emits that completion with an + // empty `turn.items`, exec does one last `thread/read` here so human/json output can recover + // the final message and reconcile any still-running items before shutdown. + let ServerNotification::TurnCompleted(payload) = notification else { + return; }; - let conversation_id = object - .get("conversationId") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - let mut event_payload = if let Some(serde_json::Value::Object(msg_payload)) = object.get("msg") - { - serde_json::Value::Object(msg_payload.clone()) - } else { - object.remove("conversationId"); - serde_json::Value::Object(object) - }; - let serde_json::Value::Object(ref mut object) = event_payload else { - return Err(format!( - "legacy notification `{method}` event payload was not an object" - )); - }; - object.insert( - "type".to_string(), - serde_json::Value::String(normalized_method), - ); + if !payload.turn.items.is_empty() { + return; + } - let msg: EventMsg = serde_json::from_value(event_payload) - .map_err(|err| format!("failed to decode event: {err}"))?; - Ok(DecodedLegacyNotification { - conversation_id, - event: Event { - id: String::new(), - msg, + let response = send_request_with_response::( + client, + ClientRequest::ThreadRead { + request_id: request_ids.next(), + params: ThreadReadParams { + thread_id: payload.thread_id.clone(), + include_turns: true, + }, }, - }) + "thread/read", + ) + .await; + + match response { + Ok(response) => { + if let Some(items) = turn_items_for_thread(&response.thread, &payload.turn.id) { + payload.turn.items = items; + } + } + Err(err) => { + warn!("thread/read failed while backfilling turn items for turn completion: {err}"); + } + } +} + +fn turn_items_for_thread( + thread: &AppServerThread, + turn_id: &str, +) -> Option> { + thread + .turns + .iter() + .find(|turn| turn.id == turn_id) + .map(|turn| turn.items.clone()) +} + +fn all_thread_source_kinds() -> Vec { + vec![ + ThreadSourceKind::Cli, + ThreadSourceKind::VsCode, + ThreadSourceKind::Exec, + ThreadSourceKind::AppServer, + ThreadSourceKind::SubAgent, + ThreadSourceKind::SubAgentReview, + ThreadSourceKind::SubAgentCompact, + ThreadSourceKind::SubAgentThreadSpawn, + ThreadSourceKind::SubAgentOther, + ThreadSourceKind::Unknown, + ] +} + +async fn latest_thread_cwd(thread: &AppServerThread) -> PathBuf { + if let Some(path) = thread.path.as_deref() + && let Some(cwd) = parse_latest_turn_context_cwd(path).await + { + return cwd; + } + thread.cwd.clone() +} + +async fn parse_latest_turn_context_cwd(path: &Path) -> Option { + let text = tokio::fs::read_to_string(path).await.ok()?; + for line in text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(rollout_line) = serde_json::from_str::(trimmed) else { + continue; + }; + if let RolloutItem::TurnContext(item) = rollout_line.item { + return Some(item.cwd); + } + } + None +} + +fn cwds_match(current_cwd: &Path, session_cwd: &Path) -> bool { + match ( + path_utils::normalize_for_path_comparison(current_cwd), + path_utils::normalize_for_path_comparison(session_cwd), + ) { + (Ok(current), Ok(session)) => current == session, + _ => current_cwd == session_cwd, + } +} + +async fn resolve_resume_thread_id( + client: &InProcessAppServerClient, + config: &Config, + args: &crate::cli::ResumeArgs, +) -> anyhow::Result> { + let model_providers = resume_lookup_model_providers(config, args); + + if args.last { + let mut cursor = None; + loop { + let response: ThreadListResponse = send_request_with_response( + client, + ClientRequest::ThreadList { + request_id: RequestId::Integer(0), + params: ThreadListParams { + cursor, + limit: Some(100), + sort_key: Some(ThreadSortKey::UpdatedAt), + model_providers: model_providers.clone(), + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: None, + search_term: None, + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + for thread in response.data { + if args.all || cwds_match(config.cwd.as_path(), &latest_thread_cwd(&thread).await) { + return Ok(Some(thread.id)); + } + } + let Some(next_cursor) = response.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } + } + + let Some(session_id) = args.session_id.as_deref() else { + return Ok(None); + }; + if Uuid::parse_str(session_id).is_ok() { + return Ok(Some(session_id.to_string())); + } + + let mut cursor = None; + loop { + let response: ThreadListResponse = send_request_with_response( + client, + ClientRequest::ThreadList { + request_id: RequestId::Integer(0), + params: ThreadListParams { + cursor, + limit: Some(100), + sort_key: Some(ThreadSortKey::UpdatedAt), + model_providers: model_providers.clone(), + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: None, + // Thread names are attached separately from rollout titles, so name + // resolution must scan the filtered list client-side instead of relying + // on the backend `search_term` filter. + search_term: None, + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + for thread in response.data { + if thread.name.as_deref() != Some(session_id) { + continue; + } + if args.all || cwds_match(config.cwd.as_path(), &latest_thread_cwd(&thread).await) { + return Ok(Some(thread.id)); + } + } + let Some(next_cursor) = response.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } +} + +fn resume_lookup_model_providers( + config: &Config, + args: &crate::cli::ResumeArgs, +) -> Option> { + if args.last { + Some(vec![config.model_provider_id.clone()]) + } else { + None + } } fn canceled_mcp_server_elicitation_response() -> Result { @@ -1205,8 +1306,6 @@ fn server_request_method_name(request: &ServerRequest) -> String { async fn handle_server_request( client: &InProcessAppServerClient, request: ServerRequest, - config: &Config, - _thread_id: &str, error_seen: &mut bool, ) { let method = server_request_method_name(&request); @@ -1228,50 +1327,6 @@ async fn handle_server_request( Err(err) => Err(err), } } - ServerRequest::ChatgptAuthTokensRefresh { request_id, params } => { - let refresh_result = tokio::task::spawn_blocking({ - let config = config.clone(); - move || local_external_chatgpt_tokens(&config) - }) - .await; - - match refresh_result { - Err(err) => { - reject_server_request( - client, - request_id, - &method, - format!("local chatgpt auth refresh task failed in exec: {err}"), - ) - .await - } - Ok(Err(reason)) => reject_server_request(client, request_id, &method, reason).await, - Ok(Ok(response)) => { - if let Some(previous_account_id) = params.previous_account_id.as_deref() - && previous_account_id != response.chatgpt_account_id - { - warn!( - "local auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", - response.chatgpt_account_id - ); - } - match serde_json::to_value(response) { - Ok(value) => { - resolve_server_request( - client, - request_id, - value, - "account/chatgptAuthTokens/refresh", - ) - .await - } - Err(err) => Err(format!( - "failed to serialize chatgpt auth refresh response: {err}" - )), - } - } - } - } ServerRequest::CommandExecutionRequestApproval { request_id, params } => { reject_server_request( client, @@ -1320,6 +1375,15 @@ async fn handle_server_request( ) .await } + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "chatgpt auth token refresh is not supported in exec mode".to_string(), + ) + .await + } ServerRequest::ApplyPatchApproval { request_id, params } => { reject_server_request( client, @@ -1364,91 +1428,6 @@ async fn handle_server_request( } } -fn local_external_chatgpt_tokens( - config: &Config, -) -> Result { - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); - auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - auth_manager.reload(); - - let auth = auth_manager - .auth_cached() - .ok_or_else(|| "no cached auth available for local token refresh".to_string())?; - if !auth.is_external_chatgpt_tokens() { - return Err("external ChatGPT token auth is not active".to_string()); - } - - let access_token = auth - .get_token() - .map_err(|err| format!("failed to read external access token: {err}"))?; - let chatgpt_account_id = auth - .get_account_id() - .ok_or_else(|| "external token auth is missing chatgpt account id".to_string())?; - let chatgpt_plan_type = auth.account_plan_type().map(|plan_type| match plan_type { - AccountPlanType::Free => "free".to_string(), - AccountPlanType::Go => "go".to_string(), - AccountPlanType::Plus => "plus".to_string(), - AccountPlanType::Pro => "pro".to_string(), - AccountPlanType::Team => "team".to_string(), - AccountPlanType::Business => "business".to_string(), - AccountPlanType::Enterprise => "enterprise".to_string(), - AccountPlanType::Edu => "edu".to_string(), - AccountPlanType::Unknown => "unknown".to_string(), - }); - - Ok(ChatgptAuthTokensRefreshResponse { - access_token, - chatgpt_account_id, - chatgpt_plan_type, - }) -} - -async fn resolve_resume_path( - config: &Config, - args: &crate::cli::ResumeArgs, -) -> anyhow::Result> { - if args.last { - let default_provider_filter = vec![config.model_provider_id.clone()]; - let filter_cwd = if args.all { - None - } else { - Some(config.cwd.as_path()) - }; - match codex_core::RolloutRecorder::find_latest_thread_path( - config, - /*page_size*/ 1, - /*cursor*/ None, - codex_core::ThreadSortKey::UpdatedAt, - &[], - Some(default_provider_filter.as_slice()), - &config.model_provider_id, - filter_cwd, - ) - .await - { - Ok(path) => Ok(path), - Err(e) => { - error!("Error listing threads: {e}"); - Ok(None) - } - } - } else if let Some(id_str) = args.session_id.as_deref() { - if Uuid::parse_str(id_str).is_ok() { - let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?; - Ok(path) - } else { - let path = find_thread_path_by_name_str(&config.codex_home, id_str).await?; - Ok(path) - } - } else { - Ok(None) - } -} - fn load_output_schema(path: Option) -> Option { let path = path?; @@ -1806,27 +1785,92 @@ mod tests { ); } - #[test] - fn decode_legacy_notification_preserves_conversation_id() { - let decoded = decode_legacy_notification(JSONRPCNotification { - method: "codex/event/error".to_string(), - params: Some(serde_json::json!({ - "conversationId": "thread-123", - "msg": { - "message": "boom" - } - })), - }) - .expect("legacy notification should decode"); + #[tokio::test] + async fn resume_lookup_model_providers_filters_only_last_lookup() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build default config"); + config.model_provider_id = "test-provider".to_string(); - assert_eq!(decoded.conversation_id.as_deref(), Some("thread-123")); - assert!(matches!( - decoded.event.msg, - EventMsg::Error(codex_protocol::protocol::ErrorEvent { - message, - codex_error_info: None, - }) if message == "boom" - )); + let last_args = crate::cli::ResumeArgs { + session_id: None, + last: true, + all: false, + images: vec![], + prompt: None, + }; + let named_args = crate::cli::ResumeArgs { + session_id: Some("named-session".to_string()), + last: false, + all: false, + images: vec![], + prompt: None, + }; + + assert_eq!( + resume_lookup_model_providers(&config, &last_args), + Some(vec!["test-provider".to_string()]) + ); + assert_eq!(resume_lookup_model_providers(&config, &named_args), None); + } + + #[test] + fn turn_items_for_thread_returns_matching_turn_items() { + let thread = AppServerThread { + id: "thread-1".to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp/project"), + cli_version: "0.0.0-test".to_string(), + source: codex_app_server_protocol::SessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![ + codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + codex_app_server_protocol::Turn { + id: "turn-2".to_string(), + items: vec![AppServerThreadItem::Plan { + id: "plan-1".to_string(), + text: "ship it".to_string(), + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + ], + }; + + assert_eq!( + turn_items_for_thread(&thread, "turn-1"), + Some(vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }]) + ); + assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None); } #[test] diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e9f295337e..58579ccc2d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1,3 +1,44 @@ +use std::path::PathBuf; + +use codex_app_server_protocol::CollabAgentState as ApiCollabAgentState; +use codex_app_server_protocol::CollabAgentStatus as ApiCollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus as ApiCollabAgentToolCallStatus; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus as ApiCommandExecutionStatus; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::FileUpdateChange as ApiFileUpdateChange; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::McpToolCallError; +use codex_app_server_protocol::McpToolCallResult; +use codex_app_server_protocol::McpToolCallStatus as ApiMcpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus as ApiPatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind as ApiPatchChangeKind; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TokenUsageBreakdown; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError; +use codex_app_server_protocol::TurnPlanStep; +use codex_app_server_protocol::TurnPlanStepStatus; +use codex_app_server_protocol::TurnPlanUpdatedNotification; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_protocol::ThreadId; +use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; +use pretty_assertions::assert_eq; +use serde_json::json; + +use codex_exec::event_processor_with_jsonl_output::CodexStatus; +use codex_exec::event_processor_with_jsonl_output::CollectedThreadEvents; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; use codex_exec::exec_events::CollabAgentState; @@ -8,6 +49,8 @@ use codex_exec::exec_events::CollabToolCallStatus; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; use codex_exec::exec_events::ErrorItem; +use codex_exec::exec_events::FileChangeItem; +use codex_exec::exec_events::FileUpdateChange as ExecFileUpdateChange; use codex_exec::exec_events::ItemCompletedEvent; use codex_exec::exec_events::ItemStartedEvent; use codex_exec::exec_events::ItemUpdatedEvent; @@ -20,1299 +63,1509 @@ use codex_exec::exec_events::PatchChangeKind; use codex_exec::exec_events::ReasoningItem; use codex_exec::exec_events::ThreadErrorEvent; use codex_exec::exec_events::ThreadEvent; -use codex_exec::exec_events::ThreadItem; +use codex_exec::exec_events::ThreadItem as ExecThreadItem; use codex_exec::exec_events::ThreadItemDetails; use codex_exec::exec_events::ThreadStartedEvent; -use codex_exec::exec_events::TodoItem as ExecTodoItem; -use codex_exec::exec_events::TodoListItem as ExecTodoListItem; +use codex_exec::exec_events::TodoItem; +use codex_exec::exec_events::TodoListItem; use codex_exec::exec_events::TurnCompletedEvent; use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; -use codex_protocol::ThreadId; -use codex_protocol::config_types::ModeKind; -use codex_protocol::mcp::CallToolResult; -use codex_protocol::models::WebSearchAction; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::AgentMessageEvent; -use codex_protocol::protocol::AgentReasoningEvent; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::CodexErrorInfo; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::ErrorEvent; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::ExecCommandOutputDeltaEvent; -use codex_protocol::protocol::ExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_protocol::protocol::ExecOutputStream; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -use codex_protocol::protocol::PatchApplyBeginEvent; -use codex_protocol::protocol::PatchApplyEndEvent; -use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::WarningEvent; -use codex_protocol::protocol::WebSearchBeginEvent; -use codex_protocol::protocol::WebSearchEndEvent; -use pretty_assertions::assert_eq; -use rmcp::model::Content; -use serde_json::json; -use std::path::PathBuf; -use std::time::Duration; - -fn event(id: &str, msg: EventMsg) -> Event { - Event { - id: id.to_string(), - msg, - } -} #[test] -fn session_configured_produces_thread_started_event() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let session_id = - codex_protocol::ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let rollout_path = PathBuf::from("/tmp/rollout.json"); - let ev = event( - "e1", - EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id, - forked_from_id: None, - thread_name: None, - model: "codex-mini-latest".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(rollout_path), - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), - })] - ); -} - -#[test] -fn task_started_produces_turn_started_event() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "t1", - EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: Some(32_000), - collaboration_mode_kind: ModeKind::Default, - }), - )); - - assert_eq!(out, vec![ThreadEvent::TurnStarted(TurnStartedEvent {})]); -} - -#[test] -fn web_search_end_emits_item_completed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let query = "rust async await".to_string(); - let action = WebSearchAction::Search { - query: Some(query.clone()), - queries: None, - }; - let out = ep.collect_thread_events(&event( - "w1", - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: "call-123".to_string(), - query: query.clone(), - action: action.clone(), - }), - )); +fn map_todo_items_preserves_text_and_completion_state() { + let items = EventProcessorWithJsonOutput::map_todo_items(&[ + TurnPlanStep { + step: "inspect bootstrap".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + TurnPlanStep { + step: "drop legacy notifications".to_string(), + status: TurnPlanStepStatus::Completed, + }, + ]); assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-123".to_string(), - query, - action, - }), - }, - })] - ); -} - -#[test] -fn web_search_begin_emits_item_started() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "w0", - EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: "call-0".to_string(), - }), - )); - - assert_eq!(out.len(), 1); - let ThreadEvent::ItemStarted(ItemStartedEvent { item }) = &out[0] else { - panic!("expected ItemStarted"); - }; - assert!(item.id.starts_with("item_")); - assert_eq!( - item.details, - ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-0".to_string(), - query: String::new(), - action: WebSearchAction::Other, - }) - ); -} - -#[test] -fn web_search_begin_then_end_reuses_item_id() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let begin = ep.collect_thread_events(&event( - "w0", - EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: "call-1".to_string(), - }), - )); - let ThreadEvent::ItemStarted(ItemStartedEvent { item: started_item }) = &begin[0] else { - panic!("expected ItemStarted"); - }; - let action = WebSearchAction::Search { - query: Some("rust async await".to_string()), - queries: None, - }; - let end = ep.collect_thread_events(&event( - "w1", - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: "call-1".to_string(), - query: "rust async await".to_string(), - action: action.clone(), - }), - )); - let ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: completed_item, - }) = &end[0] - else { - panic!("expected ItemCompleted"); - }; - - assert_eq!(completed_item.id, started_item.id); - assert_eq!( - completed_item.details, - ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-1".to_string(), - query: "rust async await".to_string(), - action, - }) - ); -} - -#[test] -fn plan_update_emits_todo_list_started_updated_and_completed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First plan update => item.started (todo_list) - let first = event( - "p1", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![ - PlanItemArg { - step: "step one".to_string(), - status: StepStatus::Pending, - }, - PlanItemArg { - step: "step two".to_string(), - status: StepStatus::InProgress, - }, - ], - }), - ); - let out_first = ep.collect_thread_events(&first); - assert_eq!( - out_first, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: false - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - })] - ); - - // Second plan update in same turn => item.updated (same id) - let second = event( - "p2", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![ - PlanItemArg { - step: "step one".to_string(), - status: StepStatus::Completed, - }, - PlanItemArg { - step: "step two".to_string(), - status: StepStatus::InProgress, - }, - ], - }), - ); - let out_second = ep.collect_thread_events(&second); - assert_eq!( - out_second, - vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: true - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - })] - ); - - // Task completes => item.completed (same id, latest state) - let complete = event( - "p3", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - let out_complete = ep.collect_thread_events(&complete); - assert_eq!( - out_complete, + items, vec![ - ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: true - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - }), - ThreadEvent::TurnCompleted(TurnCompletedEvent { - usage: Usage::default(), - }), + TodoItem { + text: "inspect bootstrap".to_string(), + completed: false, + }, + TodoItem { + text: "drop legacy notifications".to_string(), + completed: true, + }, ] ); } #[test] -fn mcp_tool_call_begin_and_end_emit_item_events() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: Some(json!({ "key": "value" })), +fn session_configured_produces_thread_started_event() { + let session_configured = SessionConfiguredEvent { + session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse"), + forked_from_id: None, + thread_name: None, + model: "codex-mini-latest".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, }; - let begin = event( - "m1", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-1".to_string(), - invocation: invocation.clone(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: json!({ "key": "value" }), - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + EventProcessorWithJsonOutput::thread_started_event(&session_configured), + ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + }) + ); +} + +#[test] +fn turn_started_emits_turn_started_event() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = + processor.collect_thread_events(ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::InProgress, + error: None, }, - })] + })); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnStarted(TurnStartedEvent {})], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn command_execution_started_and_completed_translate_to_thread_events() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let command_item = ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::InProgress, + command_actions: Vec::::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: command_item, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } ); - let end = event( - "m2", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-1".to_string(), - invocation, - duration: Duration::from_secs(1), - result: Ok(CallToolResult { - content: Vec::new(), - is_error: None, - structured_content: None, - meta: None, - }), - }), - ); - let end_events = ep.collect_thread_events(&end); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::Completed, + command_actions: Vec::::new(), + aggregated_output: Some("a.txt\n".to_string()), + exit_code: Some(0), + duration_ms: Some(3), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); assert_eq!( - end_events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: json!({ "key": "value" }), - result: Some(McpToolCallItemResult { - content: Vec::new(), - structured_content: None, + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: "a.txt\n".to_string(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, }), - error: None, - status: McpToolCallStatus::Completed, + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn empty_reasoning_items_are_ignored() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: Vec::new(), + content: vec!["raw reasoning".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); +} + +#[test] +fn unsupported_items_do_not_consume_synthetic_ids() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let ignored = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Plan { + id: "plan-1".to_string(), + text: "ignored plan".to_string(), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + ignored, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "message-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { + text: "hello".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn reasoning_items_emit_summary_not_raw_content() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["safe summary".to_string()], + content: vec!["raw reasoning".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Reasoning(ReasoningItem { + text: "safe summary".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn web_search_completion_preserves_query_and_action() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: Some(ApiWebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, }), }, - })] + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: WebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn web_search_start_and_completion_reuse_item_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: String::new(), + action: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: Some(ApiWebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: String::new(), + action: WebSearchAction::Other, + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: WebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn mcp_tool_call_begin_and_end_emit_item_events() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: ApiMcpToolCallStatus::InProgress, + arguments: json!({ "key": "value" }), + result: None, + error: None, + duration_ms: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: ApiMcpToolCallStatus::Completed, + arguments: json!({ "key": "value" }), + result: Some(McpToolCallResult { + content: Vec::new(), + structured_content: None, + }), + error: None, + duration_ms: Some(1_000), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + arguments: json!({ "key": "value" }), + result: None, + error: None, + status: McpToolCallStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + arguments: json!({ "key": "value" }), + result: Some(McpToolCallItemResult { + content: Vec::new(), + structured_content: None, + }), + error: None, + status: McpToolCallStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn mcp_tool_call_failure_sets_failed_status() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_b".to_string(), - tool: "tool_y".to_string(), - arguments: Some(json!({ "param": 42 })), - }; + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "m3", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-2".to_string(), - invocation: invocation.clone(), - }), - ); - ep.collect_thread_events(&begin); - - let end = event( - "m4", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-2".to_string(), - invocation, - duration: Duration::from_millis(5), - result: Err("tool exploded".to_string()), - }), - ); - let events = ep.collect_thread_events(&end); - assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_b".to_string(), - tool: "tool_y".to_string(), - arguments: json!({ "param": 42 }), - result: None, - error: Some(McpToolCallItemError { - message: "tool exploded".to_string(), - }), - status: McpToolCallStatus::Failed, + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-2".to_string(), + server: "server_b".to_string(), + tool: "tool_y".to_string(), + status: ApiMcpToolCallStatus::Failed, + arguments: json!({ "param": 42 }), + result: None, + error: Some(McpToolCallError { + message: "tool exploded".to_string(), }), + duration_ms: Some(5), }, - })] + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_b".to_string(), + tool: "tool_y".to_string(), + arguments: json!({ "param": 42 }), + result: None, + error: Some(McpToolCallItemError { + message: "tool exploded".to_string(), + }), + status: McpToolCallStatus::Failed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: None, - }; + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "m5", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-3".to_string(), - invocation: invocation.clone(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); - assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: serde_json::Value::Null, - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-3".to_string(), + server: "server_c".to_string(), + tool: "tool_z".to_string(), + status: ApiMcpToolCallStatus::InProgress, + arguments: serde_json::Value::Null, + result: None, + error: None, + duration_ms: None, }, - })] - ); + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-3".to_string(), + server: "server_c".to_string(), + tool: "tool_z".to_string(), + status: ApiMcpToolCallStatus::Completed, + arguments: serde_json::Value::Null, + result: Some(McpToolCallResult { + content: vec![json!({ + "type": "text", + "text": "done", + })], + structured_content: Some(json!({ "status": "ok" })), + }), + error: None, + duration_ms: Some(10), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); - let end = event( - "m6", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-3".to_string(), - invocation, - duration: Duration::from_millis(10), - result: Ok(CallToolResult { - content: vec![serde_json::to_value(Content::text("done")).unwrap()], - is_error: None, - structured_content: Some(json!({ "status": "ok" })), - meta: None, - }), - }), - ); - let events = ep.collect_thread_events(&end); assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: serde_json::Value::Null, - result: Some(McpToolCallItemResult { - content: vec![serde_json::to_value(Content::text("done")).unwrap()], - structured_content: Some(json!({ "status": "ok" })), + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_c".to_string(), + tool: "tool_z".to_string(), + arguments: serde_json::Value::Null, + result: None, + error: None, + status: McpToolCallStatus::InProgress, }), - error: None, - status: McpToolCallStatus::Completed, - }), - }, - })] + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_c".to_string(), + tool: "tool_z".to_string(), + arguments: serde_json::Value::Null, + result: Some(McpToolCallItemResult { + content: vec![json!({ + "type": "text", + "text": "done", + })], + structured_content: Some(json!({ "status": "ok" })), + }), + error: None, + status: McpToolCallStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn collab_spawn_begin_and_end_emit_item_events() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let new_thread_id = ThreadId::from_string("9e107d9d-372b-4b8c-a2a4-1d9bb3fce0c1").unwrap(); - let prompt = "draft a plan".to_string(); + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "c1", - EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id: "call-10".to_string(), - sender_thread_id, - prompt: prompt.clone(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::default(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); - assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::SpawnAgent, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids: Vec::new(), - prompt: Some(prompt.clone()), - agents_states: std::collections::HashMap::new(), - status: CollabToolCallStatus::InProgress, - }), + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::CollabAgentToolCall { + id: "collab-1".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: ApiCollabAgentToolCallStatus::InProgress, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some("draft a plan".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: None, + agents_states: std::collections::HashMap::new(), }, - })] - ); - - let end = event( - "c2", - EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { - call_id: "call-10".to_string(), - sender_thread_id, - new_thread_id: Some(new_thread_id), - new_agent_nickname: None, - new_agent_role: None, - prompt: prompt.clone(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::default(), - status: AgentStatus::Running, - }), - ); - let end_events = ep.collect_thread_events(&end); - assert_eq!( - end_events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::SpawnAgent, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids: vec![new_thread_id.to_string()], - prompt: Some(prompt), - agents_states: [( - new_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Running, - message: None, - }, - )] - .into_iter() - .collect(), - status: CollabToolCallStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn collab_wait_end_without_begin_synthesizes_failed_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let running_thread_id = ThreadId::from_string("3f76d2a0-943e-4f43-8a38-b289c9c6c3d1").unwrap(); - let failed_thread_id = ThreadId::from_string("c1dfd96e-1f0c-4f26-9b4f-1aa02c2d3c4d").unwrap(); - let mut receiver_thread_ids = vec![running_thread_id.to_string(), failed_thread_id.to_string()]; - receiver_thread_ids.sort(); - let mut statuses = std::collections::HashMap::new(); - statuses.insert( - running_thread_id, - AgentStatus::Completed(Some("done".to_string())), - ); - statuses.insert(failed_thread_id, AgentStatus::Errored("boom".to_string())); - - let end = event( - "c3", - EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { - sender_thread_id, - call_id: "call-11".to_string(), - agent_statuses: Vec::new(), - statuses: statuses.clone(), - }), - ); - let events = ep.collect_thread_events(&end); - assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::Wait, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids, - prompt: None, - agents_states: [ - ( - running_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Completed, - message: Some("done".to_string()), - }, - ), - ( - failed_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Errored, - message: Some("boom".to_string()), - }, - ), - ] - .into_iter() - .collect(), - status: CollabToolCallStatus::Failed, - }), - }, - })] - ); -} - -#[test] -fn plan_update_after_complete_starts_new_todo_list_with_new_id() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First turn: start + complete - let start = event( - "t1", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![PlanItemArg { - step: "only".to_string(), - status: StepStatus::Pending, - }], - }), - ); - let _ = ep.collect_thread_events(&start); - let complete = event( - "t2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { + thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - let _ = ep.collect_thread_events(&complete); - - // Second turn: a new todo list should have a new id - let start_again = event( - "t3", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![PlanItemArg { - step: "again".to_string(), - status: StepStatus::Pending, - }], - }), - ); - let out = ep.collect_thread_events(&start_again); - - match &out[0] { - ThreadEvent::ItemStarted(ItemStartedEvent { item }) => { - assert_eq!(&item.id, "item_1"); - } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn agent_reasoning_produces_item_completed_reasoning() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let ev = event( - "e1", - EventMsg::AgentReasoning(AgentReasoningEvent { - text: "thinking...".to_string(), - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::Reasoning(ReasoningItem { - text: "thinking...".to_string(), - }), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::CollabAgentToolCall { + id: "collab-1".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: ApiCollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: vec!["thread-child".to_string()], + prompt: Some("draft a plan".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: None, + agents_states: std::collections::HashMap::from([( + "thread-child".to_string(), + ApiCollabAgentState { + status: ApiCollabAgentStatus::Running, + message: None, + }, + )]), }, - })] - ); -} - -#[test] -fn agent_message_produces_item_completed_agent_message() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let ev = event( - "e1", - EventMsg::AgentMessage(AgentMessageEvent { - message: "hello".to_string(), - phase: None, - memory_citation: None, - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::AgentMessage(AgentMessageItem { - text: "hello".to_string(), - }), - }, - })] - ); -} - -#[test] -fn error_event_produces_error() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::Error(codex_protocol::protocol::ErrorEvent { - message: "boom".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), + thread_id: "thread-parent".to_string(), + turn_id: "turn-1".to_string(), + }, )); + assert_eq!( - out, - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "boom".to_string(), - })] + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some("draft a plan".to_string()), + agents_states: std::collections::HashMap::new(), + status: CollabToolCallStatus::InProgress, + },), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: vec!["thread-child".to_string()], + prompt: Some("draft a plan".to_string()), + agents_states: std::collections::HashMap::from([( + "thread-child".to_string(), + CollabAgentState { + status: CollabAgentStatus::Running, + message: None, + }, + )]), + status: CollabToolCallStatus::Completed, + },), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn file_change_completion_maps_change_kinds() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::FileChange { + id: "patch-1".to_string(), + changes: vec![ + ApiFileUpdateChange { + path: "a/added.txt".to_string(), + kind: ApiPatchChangeKind::Add, + diff: String::new(), + }, + ApiFileUpdateChange { + path: "b/deleted.txt".to_string(), + kind: ApiPatchChangeKind::Delete, + diff: String::new(), + }, + ApiFileUpdateChange { + path: "c/modified.txt".to_string(), + kind: ApiPatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@".to_string(), + }, + ], + status: ApiPatchApplyStatus::Completed, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: vec![ + ExecFileUpdateChange { + path: "a/added.txt".to_string(), + kind: PatchChangeKind::Add, + }, + ExecFileUpdateChange { + path: "b/deleted.txt".to_string(), + kind: PatchChangeKind::Delete, + }, + ExecFileUpdateChange { + path: "c/modified.txt".to_string(), + kind: PatchChangeKind::Update, + }, + ], + status: PatchApplyStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn file_change_declined_maps_to_failed_status() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::FileChange { + id: "patch-2".to_string(), + changes: vec![ApiFileUpdateChange { + path: "file.txt".to_string(), + kind: ApiPatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@".to_string(), + }], + status: ApiPatchApplyStatus::Declined, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: vec![ExecFileUpdateChange { + path: "file.txt".to_string(), + kind: PatchChangeKind::Update, + }], + status: PatchApplyStatus::Failed, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn agent_message_item_updates_final_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { + text: "hello".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!(processor.final_message(), Some("hello")); +} + +#[test] +fn agent_message_item_started_is_ignored() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + + assert_eq!( + collected, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); +} + +#[test] +fn reasoning_item_completed_uses_synthetic_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "rs-1".to_string(), + summary: vec!["thinking...".to_string()], + content: vec!["raw".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Reasoning(ReasoningItem { + text: "thinking...".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn warning_event_produces_error_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::Warning(WarningEvent { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), - }), + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_warning( + "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), + ); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Error(ErrorItem { + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn plan_update_emits_started_then_updated_then_completed() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![ + TurnPlanStep { + step: "step one".to_string(), + status: TurnPlanStepStatus::Pending, + }, + TurnPlanStep { + step: "step two".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + ], + }, )); assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::Error(ErrorItem { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), - }), - }, - })] + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: false, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + })], + status: CodexStatus::Running, + } ); -} -#[test] -fn stream_error_event_produces_error() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::StreamError(codex_protocol::protocol::StreamErrorEvent { - message: "retrying".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), + let updated = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![ + TurnPlanStep { + step: "step one".to_string(), + status: TurnPlanStepStatus::Completed, + }, + TurnPlanStep { + step: "step two".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + ], + }, )); assert_eq!( - out, - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "retrying".to_string(), - })] - ); -} - -#[test] -fn error_followed_by_task_complete_produces_turn_failed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - let error_event = event( - "e1", - EventMsg::Error(ErrorEvent { - message: "boom".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), - ); - assert_eq!( - ep.collect_thread_events(&error_event), - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "boom".to_string(), - })] - ); - - let complete_event = event( - "e2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - assert_eq!( - ep.collect_thread_events(&complete_event), - vec![ThreadEvent::TurnFailed(TurnFailedEvent { - error: ThreadErrorEvent { - message: "boom".to_string(), - }, - })] - ); -} - -#[test] -fn exec_command_end_success_produces_completed_command_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec!["bash".to_string(), "-lc".to_string(), "echo hi".to_string()]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - // Begin -> no output - let begin = event( - "c1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "1".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert_eq!( - out_begin, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo hi'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - // End (success) -> item.completed (item_0) - let end_ok = event( - "c2", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "1".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: "hi\n".to_string(), - exit_code: 0, - duration: Duration::from_millis(5), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out_ok = ep.collect_thread_events(&end_ok); - assert_eq!( - out_ok, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo hi'".to_string(), - aggregated_output: "hi\n".to_string(), - exit_code: Some(0), - status: CommandExecutionStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn command_execution_output_delta_updates_item_progress() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "echo delta".to_string(), - ]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - let begin = event( - "d1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "delta-1".to_string(), - process_id: Some("42".to_string()), - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert_eq!( - out_begin, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo delta'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - let delta = event( - "d2", - EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - call_id: "delta-1".to_string(), - stream: ExecOutputStream::Stdout, - chunk: b"partial output\n".to_vec(), - }), - ); - let out_delta = ep.collect_thread_events(&delta); - assert_eq!(out_delta, Vec::::new()); - - let end = event( - "d3", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "delta-1".to_string(), - process_id: Some("42".to_string()), - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: Duration::from_millis(3), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!( - out_end, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo delta'".to_string(), - aggregated_output: String::new(), - exit_code: Some(0), - status: CommandExecutionStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn exec_command_end_failure_produces_failed_command_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec!["sh".to_string(), "-c".to_string(), "exit 1".to_string()]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - // Begin -> no output - let begin = event( - "c1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "2".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - assert_eq!( - ep.collect_thread_events(&begin), - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "sh -c 'exit 1'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - // End (failure) -> item.completed (item_0) - let end_fail = event( - "c2", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "2".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 1, - duration: Duration::from_millis(2), - formatted_output: String::new(), - status: CoreExecCommandStatus::Failed, - }), - ); - let out_fail = ep.collect_thread_events(&end_fail); - assert_eq!( - out_fail, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "sh -c 'exit 1'".to_string(), - aggregated_output: String::new(), - exit_code: Some(1), - status: CommandExecutionStatus::Failed, - }), - }, - })] - ); -} - -#[test] -fn exec_command_end_without_begin_is_ignored() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // End event arrives without a prior Begin; should produce no thread events. - let end_only = event( - "c1", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "no-begin".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: Vec::new(), - cwd: PathBuf::from("."), - parsed_cmd: Vec::new(), - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: Duration::from_millis(1), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out = ep.collect_thread_events(&end_only); - assert!(out.is_empty()); -} - -#[test] -fn patch_apply_success_produces_item_completed_patchapply() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // Prepare a patch with multiple kinds of changes - let mut changes = std::collections::HashMap::new(); - changes.insert( - PathBuf::from("a/added.txt"), - FileChange::Add { - content: "+hello".to_string(), - }, - ); - changes.insert( - PathBuf::from("b/deleted.txt"), - FileChange::Delete { - content: "-goodbye".to_string(), - }, - ); - changes.insert( - PathBuf::from("c/modified.txt"), - FileChange::Update { - unified_diff: "--- c/modified.txt\n+++ c/modified.txt\n@@\n-old\n+new\n".to_string(), - move_path: Some(PathBuf::from("c/renamed.txt")), - }, - ); - - // Begin -> no output - let begin = event( - "p1", - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - auto_approved: true, - changes: changes.clone(), - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert!(out_begin.is_empty()); - - // End (success) -> item.completed (item_0) - let end = event( - "p2", - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - stdout: "applied 3 changes".to_string(), - stderr: String::new(), - success: true, - changes: changes.clone(), - status: CorePatchApplyStatus::Completed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!(out_end.len(), 1); - - // Validate structure without relying on HashMap iteration order - match &out_end[0] { - ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { - assert_eq!(&item.id, "item_0"); - match &item.details { - ThreadItemDetails::FileChange(file_update) => { - assert_eq!(file_update.status, PatchApplyStatus::Completed); - - let mut actual: Vec<(String, PatchChangeKind)> = file_update - .changes - .iter() - .map(|c| (c.path.clone(), c.kind.clone())) - .collect(); - actual.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut expected = vec![ - ("a/added.txt".to_string(), PatchChangeKind::Add), - ("b/deleted.txt".to_string(), PatchChangeKind::Delete), - ("c/modified.txt".to_string(), PatchChangeKind::Update), - ]; - expected.sort_by(|a, b| a.0.cmp(&b.0)); - - assert_eq!(actual, expected); - } - other => panic!("unexpected details: {other:?}"), - } + updated, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: true, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + })], + status: CodexStatus::Running, } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn patch_apply_failure_produces_item_completed_patchapply_failed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - let mut changes = std::collections::HashMap::new(); - changes.insert( - PathBuf::from("file.txt"), - FileChange::Update { - unified_diff: "--- file.txt\n+++ file.txt\n@@\n-old\n+new\n".to_string(), - move_path: None, - }, ); - // Begin -> no output - let begin = event( - "p1", - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-2".to_string(), - turn_id: "turn-2".to_string(), - auto_approved: false, - changes: changes.clone(), - }), - ); - assert!(ep.collect_thread_events(&begin).is_empty()); - - // End (failure) -> item.completed (item_0) with Failed status - let end = event( - "p2", - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-2".to_string(), - turn_id: "turn-2".to_string(), - stdout: String::new(), - stderr: "failed to apply".to_string(), - success: false, - changes: changes.clone(), - status: CorePatchApplyStatus::Failed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!(out_end.len(), 1); - - match &out_end[0] { - ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { - assert_eq!(&item.id, "item_0"); - match &item.details { - ThreadItemDetails::FileChange(file_update) => { - assert_eq!(file_update.status, PatchApplyStatus::Failed); - assert_eq!(file_update.changes.len(), 1); - assert_eq!(file_update.changes[0].path, "file.txt".to_string()); - assert_eq!(file_update.changes[0].kind, PatchChangeKind::Update); - } - other => panic!("unexpected details: {other:?}"), - } - } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn task_complete_produces_turn_completed_with_usage() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First, feed a TokenCount event with known totals. - let usage = codex_protocol::protocol::TokenUsage { - input_tokens: 1200, - cached_input_tokens: 200, - output_tokens: 345, - reasoning_output_tokens: 0, - total_tokens: 0, - }; - let info = codex_protocol::protocol::TokenUsageInfo { - total_token_usage: usage.clone(), - last_token_usage: usage, - model_context_window: None, - }; - let token_count_event = event( - "e1", - EventMsg::TokenCount(codex_protocol::protocol::TokenCountEvent { - info: Some(info), - rate_limits: None, - }), - ); - assert!(ep.collect_thread_events(&token_count_event).is_empty()); - - // Then TurnComplete should produce turn.completed with the captured usage. - let complete_event = event( - "e2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("done".to_string()), - }), - ); - let out = ep.collect_thread_events(&complete_event); - assert_eq!( - out, - vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { - usage: Usage { - input_tokens: 1200, - cached_input_tokens: 200, - output_tokens: 345, + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, }, - })] + }, + )); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ + ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: true, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + }), + ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + }), + ], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn plan_update_after_completion_starts_new_todo_list_with_new_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let _ = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![TurnPlanStep { + step: "only".to_string(), + status: TurnPlanStepStatus::Pending, + }], + }, + )); + let _ = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + let restarted = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-2".to_string(), + explanation: None, + plan: vec![TurnPlanStep { + step: "again".to_string(), + status: TurnPlanStepStatus::Pending, + }], + }, + )); + + assert_eq!( + restarted, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_1".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![TodoItem { + text: "again".to_string(), + completed: false, + }], + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn token_usage_update_is_emitted_on_turn_completion() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let usage_update = + processor.collect_thread_events(ServerNotification::ThreadTokenUsageUpdated( + codex_app_server_protocol::ThreadTokenUsageUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 42, + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + reasoning_output_tokens: 7, + }, + last: TokenUsageBreakdown { + total_tokens: 42, + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + reasoning_output_tokens: 7, + }, + model_context_window: Some(128_000), + }, + }, + )); + assert_eq!( + usage_update, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage { + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + }, + })], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn turn_completion_recovers_final_message_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("final answer")); +} + +#[test] +fn turn_completion_reconciles_started_items_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::InProgress, + command_actions: Vec::::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } + ); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::Completed, + command_actions: Vec::::new(), + aggregated_output: Some("a.txt\n".to_string()), + exit_code: Some(0), + duration_ms: Some(3), + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ + ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: "a.txt\n".to_string(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + }), + ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + }), + ], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn turn_completion_overwrites_stale_final_message_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-stale".to_string(), + text: "stale answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("final answer")); +} + +#[test] +fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-streamed".to_string(), + text: "streamed answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("streamed answer")); +} + +#[test] +fn failed_turn_clears_stale_final_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "partial answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(processor.final_message(), Some("partial answer")); + + let collected = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(TurnError { + message: "turn failed".to_string(), + additional_details: None, + codex_error_info: None, + }), + }, + }, + )); + + assert_eq!(collected.status, CodexStatus::InitiateShutdown); + assert_eq!(processor.final_message(), None); +} + +#[test] +fn turn_completion_falls_back_to_final_plan_text() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::Plan { + id: "plan-1".to_string(), + text: "ship the typed adapter".to_string(), + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("ship the typed adapter")); +} + +#[test] +fn turn_failure_prefers_structured_error_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let error = processor.collect_thread_events(ServerNotification::Error(ErrorNotification { + error: TurnError { + message: "backend failed".to_string(), + codex_error_info: None, + additional_details: Some("request id abc".to_string()), + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + error, + CollectedThreadEvents { + events: vec![ThreadEvent::Error(ThreadErrorEvent { + message: "backend failed (request id abc)".to_string(), + })], + status: CodexStatus::Running, + } + ); + + let failed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: None, + }, + }, + )); + assert_eq!( + failed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnFailed(TurnFailedEvent { + error: ThreadErrorEvent { + message: "backend failed (request id abc)".to_string(), + }, + })], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn model_reroute_surfaces_as_error_item() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ModelRerouted( + codex_app_server_protocol::ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5".to_string(), + to_model: "gpt-5-mini".to_string(), + reason: codex_app_server_protocol::ModelRerouteReason::HighRiskCyberActivity, + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(collected.events.len(), 1); + let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else { + panic!("expected ItemCompleted"); + }; + assert_eq!(item.id, "item_0"); + assert_eq!( + item.details, + ThreadItemDetails::Error(ErrorItem { + message: "model rerouted: gpt-5 -> gpt-5-mini (HighRiskCyberActivity)".to_string(), + }) ); } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6fb689490a..0347e6ddf2 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -39,7 +39,8 @@ mitm = false # Hosts must match the allowlist (unless denied). # Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. -# The global `*` wildcard is rejected. +# The global `*` wildcard is allowed in `allowed_domains` to delegate public-host filtering to +# `denied_domains`. # If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"] denied_domains = ["evil.example"] @@ -189,7 +190,7 @@ This section documents the protections implemented by `codex-network-proxy`, and what it can reasonably guarantee. - Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. -- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported; the global `*` wildcard is rejected. +- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported. A global `*` wildcard is allowed in `allowed_domains` to permit all public hosts by default, while `denied_domains` remains field-specific and still rejects global `*`. - Deny wins: entries in `denied_domains` always override the allowlist. - Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index 17927767f9..c586ee9c11 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -2,6 +2,7 @@ use crate::config::NetworkMode; use anyhow::Context; use anyhow::Result; +use anyhow::bail; use anyhow::ensure; use globset::GlobBuilder; use globset::GlobSet; @@ -151,19 +152,38 @@ pub(crate) fn is_global_wildcard_domain_pattern(pattern: &str) -> bool { .any(|candidate| candidate == "*") } -pub(crate) fn compile_globset(patterns: &[String]) -> Result { +#[derive(Clone, Copy, PartialEq, Eq)] +enum GlobalWildcard { + Allow, + Reject, +} + +pub(crate) fn compile_allowlist_globset(patterns: &[String]) -> Result { + compile_globset_with_policy(patterns, GlobalWildcard::Allow) +} + +pub(crate) fn compile_denylist_globset(patterns: &[String]) -> Result { + compile_globset_with_policy(patterns, GlobalWildcard::Reject) +} + +fn compile_globset_with_policy( + patterns: &[String], + global_wildcard: GlobalWildcard, +) -> Result { let mut builder = GlobSetBuilder::new(); let mut seen = HashSet::new(); for pattern in patterns { - ensure!( - !is_global_wildcard_domain_pattern(pattern), - "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com" - ); + if global_wildcard == GlobalWildcard::Reject && is_global_wildcard_domain_pattern(pattern) { + bail!( + "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com" + ); + } let pattern = normalize_pattern(pattern); // Supported domain patterns: // - "example.com": match the exact host // - "*.example.com": match any subdomain (not the apex) // - "**.example.com": match the apex and any subdomain + // - "*": match every host when explicitly enabled for allowlist compilation for candidate in expand_domain_pattern(&pattern) { if !seen.insert(candidate.clone()) { continue; @@ -333,7 +353,7 @@ mod tests { #[test] fn compile_globset_normalizes_trailing_dots() { - let set = compile_globset(&["Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("example.com")); assert_eq!(false, set.is_match("api.example.com")); @@ -341,7 +361,7 @@ mod tests { #[test] fn compile_globset_normalizes_wildcards() { - let set = compile_globset(&["*.Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["*.Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("api.example.com")); assert_eq!(false, set.is_match("example.com")); @@ -349,7 +369,7 @@ mod tests { #[test] fn compile_globset_normalizes_apex_and_subdomains() { - let set = compile_globset(&["**.Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["**.Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("example.com")); assert_eq!(true, set.is_match("api.example.com")); @@ -357,7 +377,7 @@ mod tests { #[test] fn compile_globset_normalizes_bracketed_ipv6_literals() { - let set = compile_globset(&["[::1]".to_string()]).unwrap(); + let set = compile_denylist_globset(&["[::1]".to_string()]).unwrap(); assert_eq!(true, set.is_match("::1")); } diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b634a8630e..9381fab7f4 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -819,7 +819,8 @@ mod tests { use crate::config::NetworkProxyConfig; use crate::config::NetworkProxySettings; - use crate::policy::compile_globset; + use crate::policy::compile_allowlist_globset; + use crate::policy::compile_denylist_globset; use crate::state::NetworkProxyConstraints; use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; @@ -894,6 +895,29 @@ mod tests { ); } + #[tokio::test] + async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["*".to_string()], + ..NetworkProxySettings::default() + }); + + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + + state.add_denied_domain("evil.example").await.unwrap(); + + let (allowed, denied) = state.current_patterns().await.unwrap(); + assert_eq!(allowed, vec!["*".to_string()]); + assert_eq!(denied, vec!["evil.example".to_string()]); + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + #[tokio::test] async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() { let config = NetworkProxyConfig { @@ -1089,6 +1113,28 @@ mod tests { ); } + #[tokio::test] + async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["*".to_string()], + denied_domains: vec!["evil.example".to_string()], + ..NetworkProxySettings::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("api.openai.com", 443).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + #[tokio::test] async fn host_blocked_rejects_loopback_when_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { @@ -1484,7 +1530,7 @@ mod tests { #[test] fn compile_globset_is_case_insensitive() { let patterns = vec!["ExAmPle.CoM".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); } @@ -1492,7 +1538,7 @@ mod tests { #[test] fn compile_globset_excludes_apex_for_subdomain_patterns() { let patterns = vec!["*.openai.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("openai.com")); assert!(!set.is_match("evilopenai.com")); @@ -1501,7 +1547,7 @@ mod tests { #[test] fn compile_globset_includes_apex_for_double_wildcard_patterns() { let patterns = vec!["**.openai.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("openai.com")); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("evilopenai.com")); @@ -1510,25 +1556,34 @@ mod tests { #[test] fn compile_globset_rejects_global_wildcard() { let patterns = vec!["*".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); + } + + #[test] + fn compile_globset_allows_global_wildcard_when_enabled() { + let patterns = vec!["*".to_string()]; + let set = compile_allowlist_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("api.openai.com")); + assert!(set.is_match("localhost")); } #[test] fn compile_globset_rejects_bracketed_global_wildcard() { let patterns = vec!["[*]".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() { let patterns = vec!["**.[*]".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] fn compile_globset_dedupes_patterns_without_changing_behavior() { let patterns = vec!["example.com".to_string(), "example.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); assert!(!set.is_match("not-example.com")); @@ -1537,11 +1592,11 @@ mod tests { #[test] fn compile_globset_rejects_invalid_patterns() { let patterns = vec!["[".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] - fn build_config_state_rejects_global_wildcard_allowed_domains() { + fn build_config_state_allows_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, @@ -1550,11 +1605,11 @@ mod tests { }, }; - assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok()); } #[test] - fn build_config_state_rejects_bracketed_global_wildcard_allowed_domains() { + fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, @@ -1563,7 +1618,7 @@ mod tests { }, }; - assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok()); } #[test] diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 612e6c5b5c..435e5beab4 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -2,7 +2,8 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::mitm::MitmState; use crate::policy::DomainPattern; -use crate::policy::compile_globset; +use crate::policy::compile_allowlist_globset; +use crate::policy::compile_denylist_globset; use crate::policy::is_global_wildcard_domain_pattern; use crate::runtime::ConfigState; use serde::Deserialize; @@ -59,12 +60,10 @@ pub fn build_config_state( constraints: NetworkProxyConstraints, ) -> anyhow::Result { crate::config::validate_unix_socket_allowlist_paths(&config)?; - validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains) + validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains) .map_err(NetworkProxyConstraintError::into_anyhow)?; - validate_domain_patterns("network.denied_domains", &config.network.denied_domains) - .map_err(NetworkProxyConstraintError::into_anyhow)?; - let deny_set = compile_globset(&config.network.denied_domains)?; - let allow_set = compile_globset(&config.network.allowed_domains)?; + let deny_set = compile_denylist_globset(&config.network.denied_domains)?; + let allow_set = compile_allowlist_globset(&config.network.allowed_domains)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new( config.network.allow_upstream_proxy, @@ -107,8 +106,7 @@ pub fn validate_policy_against_constraints( } let enabled = config.network.enabled; - validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains)?; - validate_domain_patterns("network.denied_domains", &config.network.denied_domains)?; + validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { @@ -208,7 +206,6 @@ pub fn validate_policy_against_constraints( } if let Some(allowed_domains) = &constraints.allowed_domains { - validate_domain_patterns("network.allowed_domains", allowed_domains)?; match constraints.allowlist_expansion_enabled { Some(true) => { let required_set: HashSet = allowed_domains @@ -288,7 +285,7 @@ pub fn validate_policy_against_constraints( } if let Some(denied_domains) = &constraints.denied_domains { - validate_domain_patterns("network.denied_domains", denied_domains)?; + validate_denylist_domain_patterns("network.denied_domains", denied_domains)?; let required_set: HashSet = denied_domains .iter() .map(|s| s.to_ascii_lowercase()) @@ -364,7 +361,7 @@ pub fn validate_policy_against_constraints( Ok(()) } -fn validate_domain_patterns( +fn validate_denylist_domain_patterns( field_name: &'static str, patterns: &[String], ) -> Result<(), NetworkProxyConstraintError> { diff --git a/codex-rs/sandboxing/BUILD.bazel b/codex-rs/sandboxing/BUILD.bazel new file mode 100644 index 0000000000..57837fa809 --- /dev/null +++ b/codex-rs/sandboxing/BUILD.bazel @@ -0,0 +1,11 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "sandboxing", + crate_name = "codex_sandboxing", + compile_data = [ + "src/restricted_read_only_platform_defaults.sbpl", + "src/seatbelt_base_policy.sbpl", + "src/seatbelt_network_policy.sbpl", + ], +) diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml new file mode 100644 index 0000000000..fc340d6502 --- /dev/null +++ b/codex-rs/sandboxing/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codex-sandboxing" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_sandboxing" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-network-proxy = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +dirs = { workspace = true } +dunce = { workspace = true } +libc = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true, features = ["log"] } +url = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs new file mode 100644 index 0000000000..8636f9ed04 --- /dev/null +++ b/codex-rs/sandboxing/src/landlock.rs @@ -0,0 +1,112 @@ +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use std::path::Path; +pub 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, + 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 { + 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 = 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_attr(not(test), allow(dead_code))] +fn create_linux_sandbox_command_args( + command: Vec, + command_cwd: &Path, + sandbox_policy_cwd: &Path, + use_legacy_landlock: bool, + allow_network_for_proxy: bool, +) -> Vec { + 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 = 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; diff --git a/codex-rs/core/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs similarity index 100% rename from codex-rs/core/src/landlock_tests.rs rename to codex-rs/sandboxing/src/landlock_tests.rs diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs new file mode 100644 index 0000000000..5eca9d480c --- /dev/null +++ b/codex-rs/sandboxing/src/lib.rs @@ -0,0 +1,17 @@ +pub mod landlock; +pub mod macos_permissions; +mod manager; +pub mod policy_transforms; +#[cfg(target_os = "macos")] +pub mod seatbelt; +#[cfg(target_os = "macos")] +mod seatbelt_permissions; + +pub use manager::SandboxCommand; +pub use manager::SandboxExecRequest; +pub use manager::SandboxManager; +pub use manager::SandboxTransformError; +pub use manager::SandboxTransformRequest; +pub use manager::SandboxType; +pub use manager::SandboxablePreference; +pub use manager::get_platform_sandbox; diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/sandboxing/src/macos_permissions.rs similarity index 98% rename from codex-rs/core/src/sandboxing/macos_permissions.rs rename to codex-rs/sandboxing/src/macos_permissions.rs index 1a409d4bdf..22314937ef 100644 --- a/codex-rs/core/src/sandboxing/macos_permissions.rs +++ b/codex-rs/sandboxing/src/macos_permissions.rs @@ -7,7 +7,7 @@ use codex_protocol::models::MacOsSeatbeltProfileExtensions; /// Merges macOS seatbelt profile extensions by taking the permissive union of /// each permission field. -pub(crate) fn merge_macos_seatbelt_profile_extensions( +pub fn merge_macos_seatbelt_profile_extensions( base: Option<&MacOsSeatbeltProfileExtensions>, permissions: Option<&MacOsSeatbeltProfileExtensions>, ) -> Option { @@ -38,7 +38,7 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions( } } -pub(crate) fn intersect_macos_seatbelt_profile_extensions( +pub fn intersect_macos_seatbelt_profile_extensions( requested: Option, granted: Option, ) -> Option { diff --git a/codex-rs/core/src/sandboxing/macos_permissions_tests.rs b/codex-rs/sandboxing/src/macos_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/sandboxing/macos_permissions_tests.rs rename to codex-rs/sandboxing/src/macos_permissions_tests.rs diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs new file mode 100644 index 0000000000..f55f53cb13 --- /dev/null +++ b/codex-rs/sandboxing/src/manager.rs @@ -0,0 +1,276 @@ +use crate::landlock::allow_network_for_proxy; +use crate::landlock::create_linux_sandbox_command_args_for_policies; +use crate::policy_transforms::EffectiveSandboxPermissions; +use crate::policy_transforms::effective_file_system_sandbox_policy; +use crate::policy_transforms::effective_network_sandbox_policy; +use crate::policy_transforms::should_require_platform_sandbox; +#[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; +use codex_network_proxy::NetworkProxy; +use codex_protocol::config_types::WindowsSandboxLevel; +#[cfg(target_os = "macos")] +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SandboxType { + None, + MacosSeatbelt, + LinuxSeccomp, + WindowsRestrictedToken, +} + +impl SandboxType { + pub fn as_metric_tag(self) -> &'static str { + match self { + SandboxType::None => "none", + SandboxType::MacosSeatbelt => "seatbelt", + SandboxType::LinuxSeccomp => "seccomp", + SandboxType::WindowsRestrictedToken => "windows_sandbox", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SandboxablePreference { + Auto, + Require, + Forbid, +} + +pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option { + if cfg!(target_os = "macos") { + Some(SandboxType::MacosSeatbelt) + } else if cfg!(target_os = "linux") { + Some(SandboxType::LinuxSeccomp) + } else if cfg!(target_os = "windows") { + if windows_sandbox_enabled { + Some(SandboxType::WindowsRestrictedToken) + } else { + None + } + } else { + None + } +} + +#[derive(Debug)] +pub struct SandboxCommand { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub additional_permissions: Option, +} + +#[derive(Debug)] +pub struct SandboxExecRequest { + pub command: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub network: Option, + pub sandbox: SandboxType, + pub windows_sandbox_level: WindowsSandboxLevel, + pub windows_sandbox_private_desktop: bool, + pub sandbox_policy: SandboxPolicy, + pub file_system_sandbox_policy: FileSystemSandboxPolicy, + pub network_sandbox_policy: NetworkSandboxPolicy, + pub arg0: Option, +} + +/// Bundled arguments for sandbox transformation. +/// +/// This keeps call sites self-documenting when several fields are optional. +pub struct SandboxTransformRequest<'a> { + pub command: SandboxCommand, + pub policy: &'a SandboxPolicy, + pub file_system_policy: &'a FileSystemSandboxPolicy, + pub network_policy: NetworkSandboxPolicy, + pub sandbox: SandboxType, + pub enforce_managed_network: bool, + // TODO(viyatb): Evaluate switching this to Option> + // to make shared ownership explicit across runtime/sandbox plumbing. + pub network: Option<&'a NetworkProxy>, + pub sandbox_policy_cwd: &'a Path, + #[cfg(target_os = "macos")] + pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, + pub codex_linux_sandbox_exe: Option<&'a PathBuf>, + pub use_legacy_landlock: bool, + pub windows_sandbox_level: WindowsSandboxLevel, + pub windows_sandbox_private_desktop: bool, +} + +#[derive(Debug)] +pub enum SandboxTransformError { + MissingLinuxSandboxExecutable, + #[cfg(not(target_os = "macos"))] + SeatbeltUnavailable, +} + +impl std::fmt::Display for SandboxTransformError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingLinuxSandboxExecutable => { + write!(f, "missing codex-linux-sandbox executable path") + } + #[cfg(not(target_os = "macos"))] + Self::SeatbeltUnavailable => write!(f, "seatbelt sandbox is only available on macOS"), + } + } +} + +impl std::error::Error for SandboxTransformError {} + +#[derive(Default)] +pub struct SandboxManager; + +impl SandboxManager { + pub fn new() -> Self { + Self + } + + pub fn select_initial( + &self, + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + pref: SandboxablePreference, + windows_sandbox_level: WindowsSandboxLevel, + has_managed_network_requirements: bool, + ) -> SandboxType { + match pref { + SandboxablePreference::Forbid => SandboxType::None, + SandboxablePreference::Require => { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } + SandboxablePreference::Auto => { + if should_require_platform_sandbox( + file_system_policy, + network_policy, + has_managed_network_requirements, + ) { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } + } + } + } + + pub fn transform( + &self, + request: SandboxTransformRequest<'_>, + ) -> Result { + let SandboxTransformRequest { + mut command, + policy, + file_system_policy, + network_policy, + sandbox, + enforce_managed_network, + network, + sandbox_policy_cwd, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions, + codex_linux_sandbox_exe, + use_legacy_landlock, + windows_sandbox_level, + windows_sandbox_private_desktop, + } = request; + #[cfg(not(target_os = "macos"))] + let macos_seatbelt_profile_extensions = None; + let additional_permissions = command.additional_permissions.take(); + let EffectiveSandboxPermissions { + sandbox_policy: effective_policy, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: effective_macos_seatbelt_profile_extensions, + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: _, + } = EffectiveSandboxPermissions::new( + policy, + macos_seatbelt_profile_extensions, + additional_permissions.as_ref(), + ); + 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 argv = Vec::with_capacity(1 + command.args.len()); + argv.push(command.program); + argv.append(&mut command.args); + + let (argv, arg0_override) = match sandbox { + SandboxType::None => (argv, None), + #[cfg(target_os = "macos")] + SandboxType::MacosSeatbelt => { + let mut args = create_seatbelt_command_args_for_policies_with_extensions( + argv.clone(), + &effective_file_system_policy, + effective_network_policy, + sandbox_policy_cwd, + enforce_managed_network, + network, + effective_macos_seatbelt_profile_extensions.as_ref(), + ); + let mut full_command = Vec::with_capacity(1 + args.len()); + full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); + full_command.append(&mut args); + (full_command, None) + } + #[cfg(not(target_os = "macos"))] + SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), + SandboxType::LinuxSeccomp => { + let exe = codex_linux_sandbox_exe + .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; + let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); + let mut args = create_linux_sandbox_command_args_for_policies( + argv.clone(), + command.cwd.as_path(), + &effective_policy, + &effective_file_system_policy, + effective_network_policy, + sandbox_policy_cwd, + use_legacy_landlock, + allow_proxy_network, + ); + let mut full_command = Vec::with_capacity(1 + args.len()); + full_command.push(exe.to_string_lossy().to_string()); + full_command.append(&mut args); + (full_command, Some("codex-linux-sandbox".to_string())) + } + #[cfg(target_os = "windows")] + SandboxType::WindowsRestrictedToken => (argv, None), + #[cfg(not(target_os = "windows"))] + SandboxType::WindowsRestrictedToken => (argv, None), + }; + + Ok(SandboxExecRequest { + command: argv, + cwd: command.cwd, + env: command.env, + network: network.cloned(), + sandbox, + windows_sandbox_level, + windows_sandbox_private_desktop, + sandbox_policy: effective_policy, + file_system_sandbox_policy: effective_file_system_policy, + network_sandbox_policy: effective_network_policy, + arg0: arg0_override, + }) + } +} + +#[cfg(test)] +#[path = "manager_tests.rs"] +mod tests; diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs new file mode 100644 index 0000000000..17921b1914 --- /dev/null +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -0,0 +1,251 @@ +use super::SandboxCommand; +use super::SandboxManager; +use super::SandboxTransformRequest; +use super::SandboxType; +use super::SandboxablePreference; +use super::get_platform_sandbox; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use dunce::canonicalize; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use tempfile::TempDir; + +#[test] +fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { + let manager = SandboxManager::new(); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, SandboxType::None); +} + +#[test] +fn danger_full_access_uses_platform_sandbox_with_network_requirements() { + let manager = SandboxManager::new(); + let expected = get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(sandbox, expected); +} + +#[test] +fn restricted_file_system_uses_platform_sandbox_without_managed_network() { + let manager = SandboxManager::new(); + let expected = get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, expected); +} + +#[test] +fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let exec_request = manager + .transform(SandboxTransformRequest { + command: SandboxCommand { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + additional_permissions: None, + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::unrestricted() + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} + +#[test] +fn transform_additional_permissions_enable_network_for_external_sandbox() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + 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 exec_request = manager + .transform(SandboxTransformRequest { + command: SandboxCommand { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + additional_permissions: Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: Some(Vec::new()), + }), + ..Default::default() + }), + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + }) + .expect("transform"); + + assert_eq!( + exec_request.sandbox_policy, + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + } + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Enabled + ); +} + +#[test] +fn transform_additional_permissions_preserves_denied_entries() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let temp_dir = TempDir::new().expect("create temp dir"); + let workspace_root = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let allowed_path = workspace_root.join("allowed").expect("allowed path"); + let denied_path = workspace_root.join("denied").expect("denied path"); + let exec_request = manager + .transform(SandboxTransformRequest { + command: SandboxCommand { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + additional_permissions: Some(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![allowed_path.clone()]), + }), + ..Default::default() + }), + }, + policy: &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + file_system_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, + }, + ]), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: allowed_path }, + access: FileSystemAccessMode::Write, + }, + ]) + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs new file mode 100644 index 0000000000..045c7e3f90 --- /dev/null +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -0,0 +1,463 @@ +use crate::macos_permissions::intersect_macos_seatbelt_profile_extensions; +use crate::macos_permissions::merge_macos_seatbelt_profile_extensions; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; +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_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use dunce::canonicalize; +use std::collections::HashSet; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveSandboxPermissions { + pub sandbox_policy: SandboxPolicy, + pub macos_seatbelt_profile_extensions: Option, +} + +impl EffectiveSandboxPermissions { + pub 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: effective_sandbox_policy(sandbox_policy, Some(additional_permissions)), + macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions( + macos_seatbelt_profile_extensions, + additional_permissions.macos.as_ref(), + ), + } + } +} + +pub fn normalize_additional_permissions( + additional_permissions: PermissionProfile, +) -> Result { + 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 fn merge_permission_profiles( + base: Option<&PermissionProfile>, + permissions: Option<&PermissionProfile>, +) -> Option { + 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, + _permission_kind: &str, +) -> Vec { + 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>, + permissions: Option<&Vec>, +) -> Option> { + 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) -> Vec { + 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, Vec) { + ( + 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, + extra_writes: Vec, +) -> 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 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, +) -> 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) +} + +pub fn effective_network_sandbox_policy( + network_policy: NetworkSandboxPolicy, + additional_permissions: Option<&PermissionProfile>, +) -> NetworkSandboxPolicy { + if additional_permissions + .is_some_and(|permissions| merge_network_access(network_policy.is_enabled(), permissions)) + { + NetworkSandboxPolicy::Enabled + } else if additional_permissions.is_some() { + NetworkSandboxPolicy::Restricted + } else { + network_policy + } +} + +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, + } + } + } + } +} + +fn effective_sandbox_policy( + sandbox_policy: &SandboxPolicy, + additional_permissions: Option<&PermissionProfile>, +) -> SandboxPolicy { + additional_permissions.map_or_else( + || sandbox_policy.clone(), + |permissions| sandbox_policy_with_additional_permissions(sandbox_policy, permissions), + ) +} + +pub 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, + } +} + +#[cfg(test)] +#[path = "policy_transforms_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/sandboxing/src/policy_transforms_tests.rs similarity index 64% rename from codex-rs/core/src/sandboxing/mod_tests.rs rename to codex-rs/sandboxing/src/policy_transforms_tests.rs index 9a7a34e49d..a9c759a205 100644 --- a/codex-rs/core/src/sandboxing/mod_tests.rs +++ b/codex-rs/sandboxing/src/policy_transforms_tests.rs @@ -1,6 +1,5 @@ #[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; @@ -8,12 +7,6 @@ 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; -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; @@ -31,10 +24,12 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; 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; @@ -44,52 +39,6 @@ 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(); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - false, - ); - assert_eq!(sandbox, SandboxType::None); -} - -#[test] -fn danger_full_access_uses_platform_sandbox_with_network_requirements() { - let manager = SandboxManager::new(); - let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - true, - ); - assert_eq!(sandbox, expected); -} - -#[test] -fn restricted_file_system_uses_platform_sandbox_without_managed_network() { - let manager = SandboxManager::new(); - let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - false, - ); - assert_eq!(sandbox, expected); -} - #[test] fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() { let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { @@ -146,51 +95,6 @@ fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_netw ); } -#[test] -fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - capture_policy: crate::exec::ExecCapturePolicy::ShellTool, - sandbox_permissions: super::SandboxPermissions::UseDefault, - additional_permissions: None, - justification: None, - }, - policy: &SandboxPolicy::ExternalSandbox { - network_access: crate::protocol::NetworkAccess::Restricted, - }, - file_system_policy: &FileSystemSandboxPolicy::unrestricted(), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - }) - .expect("transform"); - - assert_eq!( - exec_request.file_system_sandbox_policy, - FileSystemSandboxPolicy::unrestricted() - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Restricted - ); -} - #[test] fn normalize_additional_permissions_preserves_network() { let temp_dir = TempDir::new().expect("create temp dir"); @@ -407,6 +311,7 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { } ); } + #[cfg(target_os = "macos")] #[test] fn effective_permissions_merge_macos_extensions_with_additional_permissions() { @@ -502,154 +407,6 @@ fn external_sandbox_additional_permissions_can_enable_network() { ); } -#[test] -fn transform_additional_permissions_enable_network_for_external_sandbox() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - 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 exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - capture_policy: crate::exec::ExecCapturePolicy::ShellTool, - sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, - additional_permissions: Some(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), - ..Default::default() - }), - justification: None, - }, - policy: &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }, - file_system_policy: &FileSystemSandboxPolicy::unrestricted(), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - }) - .expect("transform"); - - assert_eq!( - exec_request.sandbox_policy, - SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - } - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Enabled - ); -} - -#[test] -fn transform_additional_permissions_preserves_denied_entries() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let temp_dir = TempDir::new().expect("create temp dir"); - let workspace_root = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let allowed_path = workspace_root.join("allowed").expect("allowed path"); - let denied_path = workspace_root.join("denied").expect("denied path"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - capture_policy: crate::exec::ExecCapturePolicy::ShellTool, - sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, - additional_permissions: Some(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![allowed_path.clone()]), - }), - ..Default::default() - }), - justification: None, - }, - policy: &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }, - file_system_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, - }, - ]), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - }) - .expect("transform"); - - assert_eq!( - exec_request.file_system_sandbox_policy, - FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: allowed_path }, - access: FileSystemAccessMode::Write, - }, - ]) - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Restricted - ); -} - #[test] fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/codex-rs/core/src/restricted_read_only_platform_defaults.sbpl b/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl similarity index 100% rename from codex-rs/core/src/restricted_read_only_platform_defaults.sbpl rename to codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs new file mode 100644 index 0000000000..e7cca3d2ac --- /dev/null +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -0,0 +1,540 @@ +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_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +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 tracing::warn; +use url::Url; + +use crate::seatbelt_permissions::build_seatbelt_extensions; + +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"; + +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) -> Vec { + 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, + 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 }, +} + +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::>() + .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::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 { + let mut deduped_paths: BTreeMap = 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() + } +} + +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, +} + +fn build_seatbelt_access_policy( + action: &str, + param_prefix: &str, + roots: Vec, +) -> (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))] +fn create_seatbelt_command_args_with_extensions( + command: Vec, + sandbox_policy: &SandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + 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, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + 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 = vec!["-p".to_string(), full_policy]; + let definition_args = dir_params + .into_iter() + .map(|(key, value): (String, PathBuf)| { + 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 { + 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 { + 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; diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_base_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_base_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_network_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_network_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_network_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_network_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/sandboxing/src/seatbelt_permissions.rs similarity index 96% rename from codex-rs/core/src/seatbelt_permissions.rs rename to codex-rs/sandboxing/src/seatbelt_permissions.rs index 5cbfd65094..d53e7496ad 100644 --- a/codex-rs/core/src/seatbelt_permissions.rs +++ b/codex-rs/sandboxing/src/seatbelt_permissions.rs @@ -1,12 +1,10 @@ -#![cfg(target_os = "macos")] - use std::collections::BTreeSet; use std::path::PathBuf; -pub use codex_protocol::models::MacOsAutomationPermission; -pub use codex_protocol::models::MacOsContactsPermission; -pub use codex_protocol::models::MacOsPreferencesPermission; -pub use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct SeatbeltExtensionPolicy { diff --git a/codex-rs/core/src/seatbelt_permissions_tests.rs b/codex-rs/sandboxing/src/seatbelt_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/seatbelt_permissions_tests.rs rename to codex-rs/sandboxing/src/seatbelt_permissions_tests.rs diff --git a/codex-rs/core/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs similarity index 94% rename from codex-rs/core/src/seatbelt_tests.rs rename to codex-rs/sandboxing/src/seatbelt_tests.rs index bab1362017..925285763e 100644 --- a/codex-rs/core/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -1,7 +1,7 @@ +use super::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; use super::UnixDomainSocketPolicy; -use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_for_policies_with_extensions; use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; @@ -9,18 +9,18 @@ use super::macos_dir_params; use super::normalize_path_for_sandbox; use super::unix_socket_dir_params; use super::unix_socket_policy; -use crate::protocol::ReadOnlyAccess; -use crate::protocol::SandboxPolicy; -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -use crate::seatbelt_permissions::MacOsAutomationPermission; -use crate::seatbelt_permissions::MacOsContactsPermission; -use crate::seatbelt_permissions::MacOsPreferencesPermission; -use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; @@ -105,7 +105,7 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { - value: crate::protocol::FileSystemSpecialPath::Root, + value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, @@ -277,12 +277,13 @@ sys.exit(0 if allowed else 13) #[test] fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["echo".to_string(), "ok".to_string()], &SandboxPolicy::new_read_only_policy(), cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let policy = &args[1]; assert!(policy.contains("(allow user-preference-read)")); @@ -295,7 +296,7 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { let cwd = tmp.path().join("workspace"); fs::create_dir_all(cwd.join("docs")).expect("create docs"); let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["/bin/true".to_string()], &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], @@ -308,8 +309,9 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { exclude_slash_tmp: true, }, cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); @@ -636,7 +638,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command.clone(), + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); // Build the expected policy text using a raw string for readability. // Note that the policy includes: @@ -740,8 +749,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_hooks_file_args = - create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None); + let write_hooks_file_args = create_seatbelt_command_args_with_extensions( + shell_command_git, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -771,8 +786,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_allowed_file_args = - create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None); + let write_allowed_file_args = create_seatbelt_command_args_with_extensions( + shell_command_allowed, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -833,7 +854,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -863,8 +891,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let gitdir_args = - create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None); + let gitdir_args = create_seatbelt_command_args_with_extensions( + shell_command_gitdir, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -921,12 +955,13 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( shell_command.clone(), &policy, vulnerable_root.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let tmpdir_env_var = std::env::var("TMPDIR") diff --git a/codex-rs/skills/src/assets/samples/imagegen/LICENSE.txt b/codex-rs/skills/src/assets/samples/imagegen/LICENSE.txt new file mode 100644 index 0000000000..13e25df86c --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/codex-rs/skills/src/assets/samples/imagegen/SKILL.md b/codex-rs/skills/src/assets/samples/imagegen/SKILL.md new file mode 100644 index 0000000000..88195882ef --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/SKILL.md @@ -0,0 +1,279 @@ +--- +name: "imagegen" +description: "Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas." +--- + +# Image Generation Skill + +Generates or edits images for the current project (for example website assets, game assets, UI mockups, product mockups, wireframes, logo design, photorealistic images, or infographics). + +## Top-level modes and rules + +This skill has exactly two top-level modes: + +- **Default built-in tool mode (preferred):** built-in `image_gen` tool for normal image generation and editing. Does not require `OPENAI_API_KEY`. +- **Fallback CLI mode (explicit-only):** `scripts/image_gen.py` CLI. Use only when the user explicitly asks for the CLI path. Requires `OPENAI_API_KEY`. + +Within the explicit CLI fallback only, the CLI exposes three subcommands: + +- `generate` +- `edit` +- `generate-batch` + +Rules: +- Use the built-in `image_gen` tool by default for all normal image generation and editing requests. +- Never switch to CLI fallback automatically. +- If the built-in tool fails or is unavailable, tell the user the CLI fallback exists and that it requires `OPENAI_API_KEY`. Proceed only if the user explicitly asks for that fallback. +- If the user explicitly asks for CLI mode, use the bundled `scripts/image_gen.py` workflow. Do not create one-off SDK runners. +- Never modify `scripts/image_gen.py`. If something is missing, ask the user before doing anything else. + +Built-in save-path policy: +- In built-in tool mode, Codex saves generated images under `$CODEX_HOME/*` by default. +- Do not describe or rely on OS temp as the default built-in destination. +- Do not describe or rely on a destination-path argument (if any) on the built-in `image_gen` tool. If a specific location is needed, generate first and then move or copy the selected output from `$CODEX_HOME/generated_images/...`. +- Save-path precedence in built-in mode: + 1. If the user names a destination, move or copy the selected output there. + 2. If the image is meant for the current project, move or copy the final selected image into the workspace before finishing. + 3. If the image is only for preview or brainstorming, render it inline; the underlying file can remain at the default `$CODEX_HOME/*` path. +- Never leave a project-referenced asset only at the default `$CODEX_HOME/*` path. +- Do not overwrite an existing asset unless the user explicitly asked for replacement; otherwise create a sibling versioned filename such as `hero-v2.png` or `item-icon-edited.png`. + +Shared prompt guidance for both modes lives in `references/prompting.md` and `references/sample-prompts.md`. + +Fallback-only docs/resources for CLI mode: +- `references/cli.md` +- `references/image-api.md` +- `references/codex-network.md` +- `scripts/image_gen.py` + +## When to use +- Generate a new image (concept art, product shot, cover, website hero) +- Generate a new image using one or more reference images for style, composition, or mood +- Edit an existing image (inpainting, lighting or weather transformations, background replacement, object removal, compositing, transparent background) +- Produce many assets or variants for one task + +## When not to use +- Extending or matching an existing SVG/vector icon set, logo system, or illustration library inside the repo +- Creating simple shapes, diagrams, wireframes, or icons that are better produced directly in SVG, HTML/CSS, or canvas +- Making a small project-local asset edit when the source file already exists in an editable native format +- Any task where the user clearly wants deterministic code-native output instead of a generated bitmap + +## Decision tree + +Think about two separate questions: + +1. **Intent:** is this a new image or an edit of an existing image? +2. **Execution strategy:** is this one asset or many assets/variants? + +Intent: +- If the user wants to modify an existing image while preserving parts of it, treat the request as **edit**. +- If the user provides images only as references for style, composition, mood, or subject guidance, treat the request as **generate**. +- If the user provides no images, treat the request as **generate**. + +Built-in edit semantics: +- Built-in edit mode is for images already visible in the conversation context, such as attached images or images generated earlier in the thread. +- If the user wants to edit a local image file with the built-in tool, first load it with built-in `view_image` tool so the image is visible in the conversation context, then proceed with the built-in edit flow. +- Do not promise arbitrary filesystem-path editing through the built-in tool. +- If a local file still needs direct file-path control, masks, or other explicit CLI-only parameters, use the explicit CLI fallback only when the user asks for it. +- For edits, preserve invariants aggressively and save non-destructively by default. + +Execution strategy: +- In the built-in default path, produce many assets or variants by issuing one `image_gen` call per requested asset or variant. +- In the explicit CLI fallback path, use the CLI `generate-batch` subcommand only when the user explicitly chose CLI mode and needs many prompts/assets. + +Assume the user wants a new image unless they clearly ask to change an existing one. + +## Workflow +1. Decide the top-level mode: built-in by default, fallback CLI only if explicitly requested. +2. Decide the intent: `generate` or `edit`. +3. Decide whether the output is preview-only or meant to be consumed by the current project. +4. Decide the execution strategy: single asset vs repeated built-in calls vs CLI `generate-batch`. +5. Collect inputs up front: prompt(s), exact text (verbatim), constraints/avoid list, and any input images. +6. For every input image, label its role explicitly: + - reference image + - edit target + - supporting insert/style/compositing input +7. If the edit target is only on the local filesystem and you are staying on the built-in path, inspect it with `view_image` first so the image is available in conversation context. +8. If the user asked for a photo, illustration, sprite, product image, banner, or other explicitly raster-style asset, use `image_gen` rather than substituting SVG/HTML/CSS placeholders. If the request is for an icon, logo, or UI graphic that should match existing repo-native SVG/vector/code assets, prefer editing those directly instead. +9. Augment the prompt based on specificity: + - If the user's prompt is already specific and detailed, normalize it into a clear spec without adding creative requirements. + - If the user's prompt is generic, add tasteful augmentation only when it materially improves output quality. +10. Use the built-in `image_gen` tool by default. +11. If the user explicitly chooses the CLI fallback, then and only then use the fallback-only docs for quality, `input_fidelity`, masks, output format, output paths, and network setup. +12. Inspect outputs and validate: subject, style, composition, text accuracy, and invariants/avoid items. +13. Iterate with a single targeted change, then re-check. +14. For preview-only work, render the image inline; the underlying file may remain at the default `$CODEX_HOME/generated_images/...` path. +15. For project-bound work, move or copy the selected artifact into the workspace and update any consuming code or references. Never leave a project-referenced asset only at the default `$CODEX_HOME/generated_images/...` path. +16. For batches, persist only the selected finals in the workspace unless the user explicitly asked to keep discarded variants. +17. Always report the final saved path for any workspace-bound asset, plus the final prompt and whether the built-in tool or fallback CLI mode was used. + +## Prompt augmentation + +Reformat user prompts into a structured, production-oriented spec. Make the user's goal clearer and more actionable, but do not blindly add detail. + +Treat this as prompt-shaping guidance, not a closed schema. Use only the lines that help, and add a short extra labeled line when it materially improves clarity. + +### Specificity policy + +Use the user's prompt specificity to decide how much augmentation is appropriate: + +- If the prompt is already specific and detailed, preserve that specificity and only normalize/structure it. +- If the prompt is generic, you may add tasteful augmentation when it will materially improve the result. + +Allowed augmentations: +- composition or framing hints +- polish level or intended-use hints +- practical layout guidance +- reasonable scene concreteness that supports the stated request + +Not allowed augmentations: +- extra characters or objects that are not implied by the request +- brand names, slogans, palettes, or narrative beats that are not implied +- arbitrary side-specific placement unless the surrounding layout supports it + +## Use-case taxonomy (exact slugs) + +Classify each request into one of these buckets and keep the slug consistent across prompts and references. + +Generate: +- photorealistic-natural — candid/editorial lifestyle scenes with real texture and natural lighting. +- product-mockup — product/packaging shots, catalog imagery, merch concepts. +- ui-mockup — app/web interface mockups and wireframes; specify the desired fidelity. +- infographic-diagram — diagrams/infographics with structured layout and text. +- logo-brand — logo/mark exploration, vector-friendly. +- illustration-story — comics, children’s book art, narrative scenes. +- stylized-concept — style-driven concept art, 3D/stylized renders. +- historical-scene — period-accurate/world-knowledge scenes. + +Edit: +- text-localization — translate/replace in-image text, preserve layout. +- identity-preserve — try-on, person-in-scene; lock face/body/pose. +- precise-object-edit — remove/replace a specific element (including interior swaps). +- lighting-weather — time-of-day/season/atmosphere changes only. +- background-extraction — transparent background / clean cutout. +- style-transfer — apply reference style while changing subject/scene. +- compositing — multi-image insert/merge with matched lighting/perspective. +- sketch-to-render — drawing/line art to photoreal render. + +## Shared prompt schema + +Use the following labeled spec as shared prompt scaffolding for both top-level modes: + +```text +Use case: +Asset type: +Primary request: +Input images: (optional) +Scene/backdrop: +Subject:
+Style/medium: +Composition/framing: +Lighting/mood: +Color palette: +Materials/textures: +Text (verbatim): "" +Constraints: +Avoid: +``` + +Notes: +- `Asset type` and `Input images` are prompt scaffolding, not dedicated CLI flags. +- `Scene/backdrop` refers to the visual setting. It is not the same as the fallback CLI `background` parameter, which controls output transparency behavior. +- Fallback-only execution notes such as `Quality:`, `Input fidelity:`, masks, output format, and output paths belong in the explicit CLI path only. Do not treat them as built-in `image_gen` tool arguments. + +Augmentation rules: +- Keep it short. +- Add only the details needed to improve the prompt materially. +- For edits, explicitly list invariants (`change only X; keep Y unchanged`). +- If any critical detail is missing and blocks success, ask a question; otherwise proceed. + +## Examples + +### Generation example (hero image) +```text +Use case: product-mockup +Asset type: landing page hero +Primary request: a minimal hero image of a ceramic coffee mug +Style/medium: clean product photography +Composition/framing: wide composition with usable negative space for page copy if needed +Lighting/mood: soft studio lighting +Constraints: no logos, no text, no watermark +``` + +### Edit example (invariants) +```text +Use case: precise-object-edit +Asset type: product photo background replacement +Primary request: replace only the background with a warm sunset gradient +Constraints: change only the background; keep the product and its edges unchanged; no text; no watermark +``` + +## Prompting best practices +- Structure prompt as scene/backdrop -> subject -> details -> constraints. +- Include intended use (ad, UI mock, infographic) to set the mode and polish level. +- Use camera/composition language for photorealism. +- Only use SVG/vector stand-ins when the user explicitly asked for vector output or a non-image placeholder. +- Quote exact text and specify typography + placement. +- For tricky words, spell them letter-by-letter and require verbatim rendering. +- For multi-image inputs, reference images by index and describe how they should be used. +- For edits, repeat invariants every iteration to reduce drift. +- Iterate with single-change follow-ups. +- If the prompt is generic, add only the extra detail that will materially help. +- If the prompt is already detailed, normalize it instead of expanding it. +- For explicit CLI fallback only, see `references/cli.md` and `references/image-api.md` for `quality`, `input_fidelity`, masks, output format, and output-path guidance. + +More principles shared by both modes: `references/prompting.md`. +Copy/paste specs shared by both modes: `references/sample-prompts.md`. + +## Guidance by asset type +Asset-type templates (website assets, game assets, wireframes, logo) are consolidated in `references/sample-prompts.md`. + +## Fallback CLI mode only + +### Temp and output conventions +These conventions apply only to the explicit CLI fallback. They do not describe built-in `image_gen` output behavior. +- Use `tmp/imagegen/` for intermediate files (for example JSONL batches); delete them when done. +- Write final artifacts under `output/imagegen/`. +- Use `--out` or `--out-dir` to control output paths; keep filenames stable and descriptive. + +### Dependencies +Prefer `uv` for dependency management in this repo. + +Required Python package: +```bash +uv pip install openai +``` + +Optional for downscaling only: +```bash +uv pip install pillow +``` + +Portability note: +- If you are using the installed skill outside this repo, install dependencies into that environment with its package manager. +- In uv-managed environments, `uv pip install ...` remains the preferred path. + +### Environment +- `OPENAI_API_KEY` must be set for live API calls. +- Do not ask the user for `OPENAI_API_KEY` when using the built-in `image_gen` tool. +- Never ask the user to paste the full key in chat. Ask them to set it locally and confirm when ready. + +If the key is missing, give the user these steps: +1. Create an API key in the OpenAI platform UI: https://platform.openai.com/api-keys +2. Set `OPENAI_API_KEY` as an environment variable in their system. +3. Offer to guide them through setting the environment variable for their OS/shell if needed. + +If installation is not possible in this environment, tell the user which dependency is missing and how to install it into their active environment. + +### Script-mode notes +- CLI commands + examples: `references/cli.md` +- API parameter quick reference: `references/image-api.md` +- Network approvals / sandbox settings for CLI mode: `references/codex-network.md` + +## Reference map +- `references/prompting.md`: shared prompting principles for both modes. +- `references/sample-prompts.md`: shared copy/paste prompt recipes for both modes. +- `references/cli.md`: fallback-only CLI usage via `scripts/image_gen.py`. +- `references/image-api.md`: fallback-only API/CLI parameter reference. +- `references/codex-network.md`: fallback-only network/sandbox troubleshooting for CLI mode. +- `scripts/image_gen.py`: fallback-only CLI implementation. Do not load or use it unless the user explicitly chooses CLI mode. diff --git a/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml b/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml new file mode 100644 index 0000000000..c9cfddb144 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Image Gen" + short_description: "Generate or edit images for websites, games, and more" + icon_small: "./assets/imagegen-small.svg" + icon_large: "./assets/imagegen.png" + default_prompt: "Generate or edit the visual assets for this task with the built-in `image_gen` tool by default. First confirm that the task actually calls for a raster image; if the project already has SVG/vector/code-native assets and the user wants to extend or match those, do not use this skill. If the task includes reference images, treat them as references unless the user clearly wants an existing image modified. For multi-asset requests, loop built-in calls rather than treating batch as a separate top-level mode. Only use the fallback CLI if the user explicitly asks for it, and keep CLI-only controls such as `generate-batch`, `quality`, `input_fidelity`, masks, and output paths on that fallback path." diff --git a/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen-small.svg b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen-small.svg new file mode 100644 index 0000000000..20128b2dba --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen-small.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen.png b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen.png new file mode 100644 index 0000000000..94b54541a9 Binary files /dev/null and b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen.png differ diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/cli.md b/codex-rs/skills/src/assets/samples/imagegen/references/cli.md new file mode 100644 index 0000000000..8cb0663c57 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/cli.md @@ -0,0 +1,160 @@ +# CLI reference (`scripts/image_gen.py`) + +This file is for the fallback CLI mode only. Read it only after the user explicitly asks to use `scripts/image_gen.py` instead of the built-in `image_gen` tool. + +`generate-batch` is a CLI subcommand in this fallback path. It is not a top-level mode of the skill. + +## What this CLI does +- `generate`: generate a new image from a prompt +- `edit`: edit one or more existing images +- `generate-batch`: run many generation jobs from a JSONL file + +Real API calls require **network access** + `OPENAI_API_KEY`. `--dry-run` does not. + +## Quick start (works from any repo) +Set a stable path to the skill CLI (default `CODEX_HOME` is `~/.codex`): + +``` +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +export IMAGE_GEN="$CODEX_HOME/skills/imagegen/scripts/image_gen.py" +``` + +Install dependencies into that environment with its package manager. In uv-managed environments, `uv pip install ...` remains the preferred path. + +## Quick start + +Dry-run (no API call; no network required; does not require the `openai` package): + +```bash +python "$IMAGE_GEN" generate \ + --prompt "Test" \ + --out output/imagegen/test.png \ + --dry-run +``` + +Notes: +- One-off dry-runs print the API payload and the computed output path(s). +- Repo-local finals should live under `output/imagegen/`. + +Generate (requires `OPENAI_API_KEY` + network): + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A cozy alpine cabin at dawn" \ + --size 1024x1024 \ + --out output/imagegen/alpine-cabin.png +``` + +Edit: + +```bash +python "$IMAGE_GEN" edit \ + --image input.png \ + --prompt "Replace only the background with a warm sunset" \ + --out output/imagegen/sunset-edit.png +``` + +## Guardrails +- Use the bundled CLI directly (`python "$IMAGE_GEN" ...`) after activating the correct environment. +- Do **not** create one-off runners (for example `gen_images.py`) unless the user explicitly asks for a custom wrapper. +- **Never modify** `scripts/image_gen.py`. If something is missing, ask the user before doing anything else. + +## Defaults +- Model: `gpt-image-1.5` +- Supported model family for this CLI: GPT Image models (`gpt-image-*`) +- Size: `1024x1024` +- Quality: `auto` +- Output format: `png` +- Default one-off output path: `output/imagegen/output.png` +- Background: unspecified unless `--background` is set + +## Quality, input fidelity, and masks (CLI fallback only) +These are explicit CLI controls. They are not built-in `image_gen` tool arguments. + +- `--quality` works for `generate`, `edit`, and `generate-batch`: `low|medium|high|auto` +- `--input-fidelity` is **edit-only** and validated as `low|high` +- `--mask` is **edit-only** + +Example: + +```bash +python "$IMAGE_GEN" edit \ + --image input.png \ + --prompt "Change only the background" \ + --quality high \ + --input-fidelity high \ + --out output/imagegen/background-edit.png +``` + +Mask notes: +- For multi-image edits, pass repeated `--image` flags. Their order is meaningful, so describe each image by index and role in the prompt. +- The CLI accepts a single `--mask`. +- Use a PNG mask when possible; the script treats mask handling as best-effort and does not perform full preflight validation beyond file checks/warnings. +- In the edit prompt, repeat invariants (`change only the background; keep the subject unchanged`) to reduce drift. + +## Output handling +- Use `tmp/imagegen/` for temporary JSONL inputs or scratch files. +- Use `output/imagegen/` for final outputs. +- Reruns fail if a target file already exists unless you pass `--force`. +- `--out-dir` changes one-off naming to `image_1.`, `image_2.`, and so on. +- Downscaled copies use the default suffix `-web` unless you override it. + +## Common recipes + +Generate with augmentation fields: + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A minimal hero image of a ceramic coffee mug" \ + --use-case "product-mockup" \ + --style "clean product photography" \ + --composition "wide product shot with usable negative space for page copy" \ + --constraints "no logos, no text" \ + --out output/imagegen/mug-hero.png +``` + +Generate + also write a downscaled copy for fast web loading: + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A cozy alpine cabin at dawn" \ + --size 1024x1024 \ + --downscale-max-dim 1024 \ + --out output/imagegen/alpine-cabin.png +``` + +Generate multiple prompts concurrently (async batch): + +```bash +mkdir -p tmp/imagegen output/imagegen/batch +cat > tmp/imagegen/prompts.jsonl << 'EOF' +{"prompt":"Cavernous hangar interior with a compact shuttle parked near the center","use_case":"stylized-concept","composition":"wide-angle, low-angle","lighting":"volumetric light rays through drifting fog","constraints":"no logos or trademarks; no watermark","size":"1536x1024"} +{"prompt":"Gray wolf in profile in a snowy forest","use_case":"photorealistic-natural","composition":"eye-level","constraints":"no logos or trademarks; no watermark","size":"1024x1024"} +EOF + +python "$IMAGE_GEN" generate-batch \ + --input tmp/imagegen/prompts.jsonl \ + --out-dir output/imagegen/batch \ + --concurrency 5 + +rm -f tmp/imagegen/prompts.jsonl +``` + +Notes: +- `generate-batch` requires `--out-dir`. +- generate-batch requires --out-dir. +- Use `--concurrency` to control parallelism (default `5`). +- Per-job overrides are supported in JSONL (for example `size`, `quality`, `background`, `output_format`, `output_compression`, `moderation`, `n`, `model`, `out`, and prompt-augmentation fields). +- `--n` generates multiple variants for a single prompt; `generate-batch` is for many different prompts. +- In batch mode, per-job `out` is treated as a filename under `--out-dir`. + +## CLI notes +- Supported sizes: `1024x1024`, `1536x1024`, `1024x1536`, or `auto`. +- Transparent backgrounds require `output_format` to be `png` or `webp`. +- `--prompt-file`, `--output-compression`, `--moderation`, `--max-attempts`, `--fail-fast`, `--force`, and `--no-augment` are supported. +- This CLI is intended for GPT Image models. Do not assume older non-GPT image-model behavior applies here. + +## See also +- API parameter quick reference for fallback CLI mode: `references/image-api.md` +- Prompt examples shared across both top-level modes: `references/sample-prompts.md` +- Network/sandbox notes for fallback CLI mode: `references/codex-network.md` diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md b/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md new file mode 100644 index 0000000000..249d628262 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md @@ -0,0 +1,33 @@ +# Codex network approvals / sandbox notes + +This file is for the fallback CLI mode only. Read it only after the user explicitly asks to use `scripts/image_gen.py`. + +This guidance is intentionally isolated from `SKILL.md` because it can vary by environment and may become stale. Prefer the defaults in your environment when in doubt. + +## Why am I asked to approve image generation calls? +The fallback CLI uses the OpenAI Image API, so it needs outbound network access. In many Codex setups, network access is disabled by default and/or the approval policy requires confirmation before networked commands run. + +## Important note about approvals vs network +- `--ask-for-approval never` suppresses approval prompts. +- It does **not** by itself enable network access. +- In `workspace-write`, network access still depends on your Codex configuration (for example `[sandbox_workspace_write] network_access = true`). + +## How do I reduce repeated approval prompts? +If you trust the repo and want fewer prompts, use a configuration or profile that both: +- enables network for the sandbox mode you plan to use +- sets an approval policy that matches your risk tolerance + +Example `~/.codex/config.toml` pattern: + +```toml +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true +``` + +If you want quieter automation after network is enabled, you can choose a stricter approval policy, but do that intentionally and with care. + +## Safety note +Enabling network and reducing approvals lowers friction, but increases risk if you run untrusted code or work in an untrusted repository. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md b/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md new file mode 100644 index 0000000000..a2750c15c6 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md @@ -0,0 +1,49 @@ +# Image API quick reference + +This file is for the fallback CLI mode only. Use it only after the user explicitly asks to use `scripts/image_gen.py` instead of the built-in `image_gen` tool. + +These parameters describe the Image API and bundled CLI fallback surface. Do not assume they are normal arguments on the built-in `image_gen` tool. + +## Scope +- This fallback CLI is intended for GPT Image models (`gpt-image-1.5`, `gpt-image-1`, and `gpt-image-1-mini`). +- The built-in `image_gen` tool and the fallback CLI do not expose the same controls. + +## Endpoints +- Generate: `POST /v1/images/generations` (`client.images.generate(...)`) +- Edit: `POST /v1/images/edits` (`client.images.edit(...)`) + +## Core parameters for GPT Image models +- `prompt`: text prompt +- `model`: image model +- `n`: number of images (1-10) +- `size`: `1024x1024`, `1536x1024`, `1024x1536`, or `auto` +- `quality`: `low`, `medium`, `high`, or `auto` +- `background`: output transparency behavior (`transparent`, `opaque`, or `auto`) for generated output; this is not the same thing as the prompt's visual scene/backdrop +- `output_format`: `png` (default), `jpeg`, `webp` +- `output_compression`: 0-100 (jpeg/webp only) +- `moderation`: `auto` (default) or `low` + +## Edit-specific parameters +- `image`: one or more input images. For GPT Image models, you can provide up to 16 images. +- `mask`: optional mask image +- `input_fidelity`: `low` (default) or `high` + +Model-specific note for `input_fidelity`: +- `gpt-image-1` and `gpt-image-1-mini` preserve all input images, but the first image gets richer textures and finer details. +- `gpt-image-1.5` preserves the first 5 input images with higher fidelity. + +## Output +- `data[]` list with `b64_json` per image +- The bundled `scripts/image_gen.py` CLI decodes `b64_json` and writes output files for you. + +## Limits and notes +- Input images and masks must be under 50MB. +- Use the edits endpoint when the user requests changes to an existing image. +- Masking is prompt-guided; exact shapes are not guaranteed. +- Large sizes and high quality increase latency and cost. +- High `input_fidelity` can materially increase input token usage. +- If a request fails because a specific option is unsupported by the selected GPT Image model, retry manually without that option. + +## Important boundary +- `quality`, `input_fidelity`, explicit masks, `background`, `output_format`, and related parameters are fallback-only execution controls. +- Do not assume they are built-in `image_gen` tool arguments. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md b/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md new file mode 100644 index 0000000000..8b6c684eef --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md @@ -0,0 +1,98 @@ +# Prompting best practices + +These prompting principles are shared by both top-level modes of the skill: +- built-in `image_gen` tool (default) +- explicit `scripts/image_gen.py` CLI fallback + +This file is about prompt structure, specificity, and iteration. Fallback-only execution controls such as `quality`, `input_fidelity`, masks, output format, and output paths live in the fallback docs. + +## Contents +- [Structure](#structure) +- [Specificity policy](#specificity-policy) +- [Allowed and disallowed augmentation](#allowed-and-disallowed-augmentation) +- [Composition and layout](#composition-and-layout) +- [Constraints and invariants](#constraints-and-invariants) +- [Text in images](#text-in-images) +- [Input images and references](#input-images-and-references) +- [Iterate deliberately](#iterate-deliberately) +- [Fallback-only execution controls](#fallback-only-execution-controls) +- [Use-case tips](#use-case-tips) +- [Where to find copy/paste recipes](#where-to-find-copypaste-recipes) + +## Structure +- Use a consistent order: scene/backdrop -> subject -> key details -> constraints -> output intent. +- Include intended use (ad, UI mock, infographic) to set the level of polish. +- For complex requests, use short labeled lines instead of one long paragraph. + +## Specificity policy +- If the user prompt is already specific and detailed, normalize it into a clean spec without adding creative requirements. +- If the prompt is generic, you may add tasteful detail when it materially improves the output. +- Treat examples in `sample-prompts.md` as fully-authored recipes, not as the default amount of augmentation to add to every request. + +## Allowed and disallowed augmentation + +Allowed augmentation for generic prompts: +- composition and framing cues +- intended-use or polish-level hints +- practical layout guidance +- reasonable scene concreteness that supports the request + +Do not add: +- extra characters, props, or objects that are not implied +- brand palettes, slogans, or story beats that are not implied +- arbitrary side-specific placement unless the surrounding layout supports it + +## Composition and layout +- Specify framing and viewpoint (close-up, wide, top-down) and placement only when it materially helps. +- Call out negative space if the asset clearly needs room for UI or copy. +- Avoid making left/right layout decisions unless the user or surrounding layout supports them. + +## Constraints and invariants +- State what must not change (`keep background unchanged`). +- For edits, say `change only X; keep Y unchanged` and repeat invariants on every iteration to reduce drift. + +## Text in images +- Put literal text in quotes or ALL CAPS and specify typography (font style, size, color, placement). +- Spell uncommon words letter-by-letter if accuracy matters. +- For in-image copy, require verbatim rendering and no extra characters. + +## Input images and references +- Do not assume that every provided image is an edit target. +- Label each image by index and role (`Image 1: edit target`, `Image 2: style reference`). +- If the user provides images for style, composition, or mood guidance and does not ask to modify them, treat the request as generation with references. +- If the user asks to preserve an existing image while changing specific parts, treat the request as an edit. +- For compositing, describe how the images interact (`place the subject from Image 2 into Image 1`). + +## Iterate deliberately +- Start with a clean base prompt, then make small single-change edits. +- Re-specify critical constraints when you iterate. +- Prefer one targeted follow-up at a time over rewriting the whole prompt. + +## Fallback-only execution controls +- `quality`, `input_fidelity`, explicit masks, output format, and output paths are fallback-only execution controls. +- Do not assume they are built-in `image_gen` tool arguments. +- If the user explicitly chooses CLI fallback, see `references/cli.md` and `references/image-api.md` for those controls. + +## Use-case tips +Generate: +- photorealistic-natural: Prompt as if a real photo is captured in the moment; use photography language (lens, lighting, framing); call for real texture; avoid over-stylized polish unless requested. +- product-mockup: Describe the product/packaging and materials; ensure clean silhouette and label clarity; if in-image text is needed, require verbatim rendering and specify typography. +- ui-mockup: Describe the target fidelity first (shippable mockup or low-fi wireframe), then focus on layout, hierarchy, and practical UI elements; avoid concept-art language. +- infographic-diagram: Define the audience and layout flow; label parts explicitly; require verbatim text. +- logo-brand: Keep it simple and scalable; ask for a strong silhouette and balanced negative space; avoid decorative flourishes unless requested. +- illustration-story: Define panels or scene beats; keep each action concrete. +- stylized-concept: Specify style cues, material finish, and rendering approach (3D, painterly, clay) without inventing new story elements. +- historical-scene: State the location/date and required period accuracy; constrain clothing, props, and environment to match the era. + +Edit: +- text-localization: Change only the text; preserve layout, typography, spacing, and hierarchy; no extra words or reflow unless needed. +- identity-preserve: Lock identity (face, body, pose, hair, expression); change only the specified elements; match lighting and shadows. +- precise-object-edit: Specify exactly what to remove/replace; preserve surrounding texture and lighting; keep everything else unchanged. +- lighting-weather: Change only environmental conditions (light, shadows, atmosphere, precipitation); keep geometry, framing, and subject identity. +- background-extraction: Request a clean cutout; crisp silhouette; no halos; preserve label text exactly; no restyling. +- style-transfer: Specify style cues to preserve (palette, texture, brushwork) and what must change; add `no extra elements` to prevent drift. +- compositing: Reference inputs by index; specify what moves where; match lighting, perspective, and scale; keep the base framing unchanged. +- sketch-to-render: Preserve layout, proportions, and perspective; choose materials and lighting that support the supplied sketch without adding new elements. + +## Where to find copy/paste recipes +For copy/paste prompt specs (examples only), see `references/sample-prompts.md`. This file focuses on principles, specificity, and iteration patterns. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md b/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md new file mode 100644 index 0000000000..e4b2b01da0 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md @@ -0,0 +1,376 @@ +# Sample prompts (copy/paste) + +These prompt recipes are shared across both top-level modes of the skill: +- built-in `image_gen` tool (default) +- explicit `scripts/image_gen.py` CLI fallback + +Use these as starting points. They are intentionally complete prompt recipes, not the default amount of augmentation to add to every user request. + +When adapting a user's prompt: +- keep user-provided requirements +- only add detail according to the specificity policy in `SKILL.md` +- do not treat every example below as permission to invent extra story elements + +The labeled lines are prompt scaffolding, not a closed schema. `Asset type` and `Input images` are prompt-only scaffolding; the CLI does not expose them as dedicated flags. + +Execution details such as explicit CLI flags, `quality`, `input_fidelity`, masks, output formats, and local output paths depend on mode. Use the built-in tool by default; only apply CLI-specific controls after the user explicitly opts into fallback mode. + +For prompting principles (structure, specificity, invariants, iteration), see `references/prompting.md`. + +## Generate + +### photorealistic-natural +``` +Use case: photorealistic-natural +Primary request: candid photo of an elderly sailor on a small fishing boat adjusting a net +Scene/backdrop: coastal water with soft haze +Subject: weathered skin with wrinkles and sun texture +Style/medium: photorealistic candid photo +Composition/framing: medium close-up, eye-level +Lighting/mood: soft coastal daylight, shallow depth of field, subtle film grain +Materials/textures: real skin texture, worn fabric, salt-worn wood +Constraints: natural color balance; no heavy retouching; no glamorization; no watermark +Avoid: studio polish; staged look +``` + +### product-mockup +``` +Use case: product-mockup +Primary request: premium product photo of a matte black shampoo bottle with a minimal label +Scene/backdrop: clean studio gradient from light gray to white +Subject: single bottle centered with subtle reflection +Style/medium: premium product photography +Composition/framing: centered, slight three-quarter angle, generous padding +Lighting/mood: softbox lighting, clean highlights, controlled shadows +Materials/textures: matte plastic, crisp label printing +Constraints: no logos or trademarks; no watermark +``` + +### ui-mockup +``` +Use case: ui-mockup +Primary request: mobile app home screen for a local farmers market with vendors and daily specials +Asset type: mobile app screen +Style/medium: realistic product UI, not concept art +Composition/framing: clean vertical mobile layout with clear hierarchy +Constraints: practical layout, clear typography, no logos or trademarks, no watermark +``` + +### infographic-diagram +``` +Use case: infographic-diagram +Primary request: detailed infographic of an automatic coffee machine flow +Scene/backdrop: clean, light neutral background +Subject: bean hopper -> grinder -> brew group -> boiler -> water tank -> drip tray +Style/medium: clean vector-like infographic with clear callouts and arrows +Composition/framing: vertical poster layout, top-to-bottom flow +Text (verbatim): "Bean Hopper", "Grinder", "Brew Group", "Boiler", "Water Tank", "Drip Tray" +Constraints: clear labels, strong contrast, no logos or trademarks, no watermark +``` + +### logo-brand +``` +Use case: logo-brand +Primary request: original logo for "Field & Flour", a local bakery +Style/medium: vector logo mark; flat colors; minimal +Composition/framing: single centered logo on a plain background with generous padding +Constraints: strong silhouette, balanced negative space; original design only; no gradients unless essential; no trademarks; no watermark +``` + +### illustration-story +``` +Use case: illustration-story +Primary request: 4-panel comic about a pet left alone at home +Scene/backdrop: cozy living room across panels +Subject: pet reacting to the owner leaving, then relaxing, then returning to a composed pose +Style/medium: comic illustration with clear panels +Composition/framing: 4 equal-sized vertical panels, readable actions per panel +Constraints: no text; no logos or trademarks; no watermark +``` + +### stylized-concept +``` +Use case: stylized-concept +Primary request: cavernous hangar interior with tall support beams and drifting fog +Scene/backdrop: industrial hangar interior, deep scale, light haze +Subject: compact shuttle parked near the center +Style/medium: cinematic concept art, industrial realism +Composition/framing: wide-angle, low-angle +Lighting/mood: volumetric light rays cutting through fog +Constraints: no logos or trademarks; no watermark +``` + +### historical-scene +``` +Use case: historical-scene +Primary request: outdoor crowd scene in Bethel, New York on August 16, 1969 +Scene/backdrop: open field with period-appropriate staging +Subject: crowd in period-accurate clothing, authentic environment +Style/medium: photorealistic photo +Composition/framing: wide shot, eye-level +Constraints: period-accurate details; no modern objects; no logos or trademarks; no watermark +``` + +## Asset type templates (taxonomy-aligned) + +### Website assets template +``` +Use case: +Asset type: +Primary request: +Scene/backdrop: +Subject:
+Style/medium: +Composition/framing: +Lighting/mood: +Color palette: +Constraints: +``` + +### Website assets example: minimal hero background +``` +Use case: stylized-concept +Asset type: landing page hero background +Primary request: minimal abstract background with a soft gradient and subtle texture +Style/medium: matte illustration / soft-rendered abstract background +Composition/framing: wide composition with usable negative space for page copy +Lighting/mood: gentle studio glow +Color palette: restrained neutral palette +Constraints: no text; no logos; no watermark +``` + +### Website assets example: feature section illustration +``` +Use case: stylized-concept +Asset type: feature section illustration +Primary request: simple abstract shapes suggesting connection and flow +Scene/backdrop: subtle light-gray backdrop with faint texture +Style/medium: flat illustration; soft shadows; restrained contrast +Composition/framing: centered cluster; open margins for UI +Color palette: muted neutral palette +Constraints: no text; no logos; no watermark +``` + +### Website assets example: blog header image +``` +Use case: photorealistic-natural +Asset type: blog header image +Primary request: overhead desk scene with notebook, pen, and coffee cup +Scene/backdrop: warm wooden tabletop +Style/medium: photorealistic photo +Composition/framing: wide crop with clean room for page copy +Lighting/mood: soft morning light +Constraints: no text; no logos; no watermark +``` + +### Game assets template +``` +Use case: stylized-concept +Asset type: +Primary request: +Scene/backdrop: (if applicable) +Subject:
+Style/medium: ; +Composition/framing: ; ; +Lighting/mood: