From 6f6af0fce4381233fa2e83dc24641167e68dfcb5 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 23 Sep 2026 14:47:51 +0000 Subject: [PATCH] Record sandbox backends in command execution analytics (#47568) ## Why Command execution analytics lack the observed sandbox backend. Older execution servers may omit this information, so an unknown backend must remain distinguishable from an explicitly unsandboxed process. ## What changed - Carry optional `sandbox_type` metadata through command execution items and emit its metric tag as `sandbox_backend` in analytics. - Propagate the observed backend through unified execution completion and failure events, including asynchronous exits. - Preserve missing backend information as `None` and mark executed user shell commands as `Some(SandboxType::None)`. - Exclude the metadata from serialized command items and generated schemas and TypeScript types. ## Testing Extend tests for missing and explicit executor backends, backend propagation on failed execution, and analytics payloads, including platform-specific backend tags in app-server integration coverage. GitOrigin-RevId: 178fe46824a6194910802ec02168ed40ee0b2019 --- codex-rs/analytics/src/events.rs | 1 + codex-rs/analytics/src/reducer.rs | 3 ++ .../analytics/src/tests/suite/tools_tests.rs | 8 +++++ codex-rs/analytics/src/tests/support.rs | 1 + .../src/protocol/item_builders.rs | 4 +++ .../src/protocol/thread_history.rs | 8 +++++ .../src/protocol/v2/item.rs | 5 ++++ .../src/protocol/v2/tests.rs | 2 ++ .../app-server/src/bespoke_event_handling.rs | 3 ++ .../app-server/tests/suite/v2/analytics.rs | 2 ++ codex-rs/core/src/tasks/user_shell.rs | 4 +++ codex-rs/core/src/tools/events.rs | 4 +++ .../core/src/unified_exec/async_watcher.rs | 10 +++++-- codex-rs/core/src/unified_exec/process.rs | 12 ++++---- .../core/src/unified_exec/process_manager.rs | 6 ++++ .../src/unified_exec/process_manager_tests.rs | 5 ++++ .../core/src/unified_exec/process_tests.rs | 29 ++++++++++--------- .../tests/event_processor_with_json_output.rs | 4 +++ codex-rs/protocol/src/items.rs | 5 ++++ codex-rs/protocol/src/protocol.rs | 2 ++ .../local/rollout_migration/legacy_event.rs | 1 + .../tui/src/app/agent_status_feed_tests.rs | 1 + codex-rs/tui/src/chatwidget/interrupts.rs | 1 + .../tui/src/chatwidget/tests/app_server.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 3 ++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 3 ++ .../chatwidget/tests/history_projection.rs | 2 ++ .../src/chatwidget/tests/status_and_layout.rs | 2 ++ .../tui/src/thread_transcript/tools_tests.rs | 1 + 29 files changed, 113 insertions(+), 21 deletions(-) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 365c2ff480..829287f666 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -793,6 +793,7 @@ pub(crate) enum WebSearchActionKind { #[derive(Serialize)] pub(crate) struct CodexCommandExecutionEventParams { + pub(crate) sandbox_backend: Option, pub(crate) model_slug: Option, pub(crate) reasoning_effort: Option, #[serde(flatten)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d772044f3e..29d1448397 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2772,6 +2772,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { match item { ThreadItem::CommandExecution { id, + sandbox_type, plugin_id, script_path, source, @@ -2806,6 +2807,8 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { CodexCommandExecutionEventRequest { event_type: "codex_command_execution_event", event_params: CodexCommandExecutionEventParams { + sandbox_backend: sandbox_type + .map(|sandbox| sandbox.as_metric_tag().to_string()), model_slug: model_context.map(|context| context.model_slug.clone()), reasoning_effort: model_context .and_then(|context| context.reasoning_effort.clone()), diff --git a/codex-rs/analytics/src/tests/suite/tools_tests.rs b/codex-rs/analytics/src/tests/suite/tools_tests.rs index fab1d9aaf0..472cd83b9d 100644 --- a/codex-rs/analytics/src/tests/suite/tools_tests.rs +++ b/codex-rs/analytics/src/tests/suite/tools_tests.rs @@ -49,6 +49,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_protocol::protocol::ThreadSource; +use codex_protocol::sandbox::SandboxType; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -61,12 +62,14 @@ fn sample_command_execution_item_with_actions( command_actions: Vec, plugin_id: Option<&str>, script_path: Option<&str>, + sandbox_type: Option, ) -> ThreadItem { let mut item = sample_command_execution_item(status, exit_code, duration_ms); let ThreadItem::CommandExecution { command_actions: item_command_actions, plugin_id: item_plugin_id, script_path: item_script_path, + sandbox_type: item_sandbox_type, .. } = &mut item else { @@ -75,6 +78,7 @@ fn sample_command_execution_item_with_actions( *item_command_actions = command_actions; *item_plugin_id = plugin_id.map(str::to_string); *item_script_path = script_path.map(str::to_string); + *item_sandbox_type = sandbox_type; item } @@ -96,6 +100,7 @@ fn command_execution_event_serializes_expected_shape() { let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest { event_type: "codex_command_execution_event", event_params: CodexCommandExecutionEventParams { + sandbox_backend: None, model_slug: None, reasoning_effort: None, base: CodexToolItemEventBase { @@ -155,6 +160,7 @@ fn command_execution_event_serializes_expected_shape() { let mut expected = json!({ "event_type": "codex_command_execution_event", "event_params": { + "sandbox_backend": null, "model_slug": null, "reasoning_effort": null, "thread_id": "thread-1", @@ -291,6 +297,7 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { ], Some("sample@openai-curated"), Some("scripts/run.py"), + Some(SandboxType::WindowsMxc), ), }, ))), @@ -301,6 +308,7 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_params"]["model_slug"], "invoking-model"); + assert_eq!(payload[0]["event_params"]["sandbox_backend"], "windows_mxc"); assert_eq!(payload[0]["event_params"]["reasoning_effort"], "max"); assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); diff --git a/codex-rs/analytics/src/tests/support.rs b/codex-rs/analytics/src/tests/support.rs index 4386962b6a..5ce1c77216 100644 --- a/codex-rs/analytics/src/tests/support.rs +++ b/codex-rs/analytics/src/tests/support.rs @@ -647,6 +647,7 @@ pub(super) fn sample_command_execution_item_with_id( ) -> ThreadItem { ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: id.to_string(), plugin_id: None, script_path: None, diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index a41283ffce..1ace0e8a6e 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -98,6 +98,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: payload.call_id.clone(), plugin_id: payload.plugin_id.clone(), script_path: payload.script_path.clone(), @@ -125,6 +126,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: payload.call_id.clone(), plugin_id: payload.plugin_id.clone(), script_path: payload.script_path.clone(), @@ -202,6 +204,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), model_context: assessment.model_context.clone(), + sandbox_type: None, plugin_id: assessment.plugin_id.clone(), script_path: assessment.script_path.clone(), command, @@ -241,6 +244,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), model_context: assessment.model_context.clone(), + sandbox_type: None, plugin_id: assessment.plugin_id.clone(), script_path: assessment.script_path.clone(), command, 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 90df776957..d31d414e88 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2391,6 +2391,7 @@ mod tests { }]; let command_item = CoreTurnItem::CommandExecution(CoreCommandExecutionItem { model_context: None, + sandbox_type: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -2477,6 +2478,7 @@ mod tests { build_turns_from_rollout_items(&items[..2])[0].items, vec![ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -2503,6 +2505,7 @@ mod tests { turns[0].items, vec![ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -3191,6 +3194,7 @@ mod tests { turns[0].items[2], ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "exec-1".into(), plugin_id: None, script_path: None, @@ -3465,6 +3469,7 @@ mod tests { turns[0].items[1], ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "exec-declined".into(), plugin_id: None, script_path: None, @@ -3575,6 +3580,7 @@ mod tests { turns[0].items[1], ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "guardian-exec".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), @@ -3649,6 +3655,7 @@ mod tests { turns[0].items[1], ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "guardian-execve".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), @@ -3852,6 +3859,7 @@ mod tests { turns[0].items[1], ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "exec-late".into(), plugin_id: None, script_path: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 06f1bc7117..04f68a3351 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -289,6 +289,10 @@ pub enum ThreadItem { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] CommandExecution { + #[serde(skip)] + #[schemars(skip)] + #[ts(skip)] + sandbox_type: Option, #[serde(skip)] #[schemars(skip)] #[ts(skip)] @@ -919,6 +923,7 @@ impl From for ThreadItem { ThreadItem::CommandExecution { id: command.id, model_context: command.model_context, + sandbox_type: command.sandbox_type, plugin_id: command.plugin_id, script_path: command.script_path, command: presentation.command, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 7b8f7913e7..a1923f58d0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3272,6 +3272,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { let command_item = TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -3305,6 +3306,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ThreadItem::from(command_item), ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c88258fa56..c5a87f6eed 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1422,6 +1422,7 @@ async fn start_command_execution_item( item: ThreadItem::CommandExecution { id: item_id, model_context, + sandbox_type: None, plugin_id, script_path, command, @@ -1467,6 +1468,7 @@ async fn complete_command_execution_item( let item = ThreadItem::CommandExecution { id: item_id, model_context: completion_item.model_context, + sandbox_type: None, plugin_id: completion_item.plugin_id, script_path: completion_item.script_path, command: completion_item.command, @@ -2484,6 +2486,7 @@ mod tests { payload.item, ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "cmd-1".to_string(), plugin_id: completion_item.plugin_id.clone(), script_path: completion_item.script_path.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 61f548479e..c6ca49bb68 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -963,6 +963,7 @@ enabled = true assert_eq!( json!({ "model_slug": command_event["event_params"]["model_slug"], + "sandbox_backend": command_event["event_params"]["sandbox_backend"], "reasoning_effort": command_event["event_params"]["reasoning_effort"], "plugin_id": command_event["event_params"]["plugin_id"], "script_path": command_event["event_params"]["script_path"], @@ -971,6 +972,7 @@ enabled = true }), json!({ "model_slug": "invoking-model", + "sandbox_backend": if cfg!(target_os = "macos") { "seatbelt" } else { "seccomp" }, "reasoning_effort": "high", "plugin_id": METRICS_PLUGIN_ID, "script_path": "scripts/run.sh", diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 80cbeef485..e802122c0f 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -189,6 +189,7 @@ pub(crate) async fn execute_user_shell_command( turn_context.as_ref(), &TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: None, id: call_id.clone(), plugin_id: None, script_path: None, @@ -269,6 +270,7 @@ pub(crate) async fn execute_user_shell_command( turn_context.as_ref(), TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: None, id: call_id, plugin_id: None, script_path: None, @@ -295,6 +297,7 @@ pub(crate) async fn execute_user_shell_command( turn_context.as_ref(), TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: Some(SandboxType::None), id: call_id.clone(), plugin_id: None, script_path: None, @@ -341,6 +344,7 @@ pub(crate) async fn execute_user_shell_command( turn_context.as_ref(), TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: None, id: call_id, plugin_id: None, script_path: None, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 18733704a6..61eedddc7d 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -44,6 +44,7 @@ pub(super) fn truncate_rejection_message(message: &str) -> String { #[derive(Clone, Copy)] pub(crate) struct ToolEventCtx<'a> { + pub sandbox_type: Option, pub model_context: Option<&'a ModelInvocationContext>, pub session: &'a Session, pub turn: &'a TurnContext, @@ -63,6 +64,7 @@ impl<'a> ToolEventCtx<'a> { ) -> Self { Self { model_context: None, + sandbox_type: None, session, turn, model_info, @@ -173,6 +175,7 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand &TurnItem::CommandExecution(CommandExecutionItem { id: ctx.call_id.to_string(), model_context: ctx.model_context.cloned(), + sandbox_type: ctx.sandbox_type, plugin_id, script_path, process_id: exec_input.process_id.map(str::to_owned), @@ -587,6 +590,7 @@ async fn emit_exec_end( TurnItem::CommandExecution(CommandExecutionItem { id: ctx.call_id.to_string(), model_context: ctx.model_context.cloned(), + sandbox_type: ctx.sandbox_type, plugin_id, script_path, process_id: exec_input.process_id.map(str::to_owned), diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 245bdb0d54..283846d67a 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -202,6 +202,7 @@ pub(crate) fn spawn_exit_watcher( if let Some(message) = process.failure_message() { drop(plugin_metrics_sidecar); emit_failed_exec_end_for_unified_exec( + process.sandbox_type(), session_ref, turn_ref, model_info, @@ -229,6 +230,7 @@ pub(crate) fn spawn_exit_watcher( ) .await; emit_exec_end_for_unified_exec( + process.sandbox_type(), session_ref, turn_ref, model_info, @@ -342,6 +344,7 @@ impl Emitter { /// text when the transcript is empty. #[allow(clippy::too_many_arguments)] pub(crate) async fn emit_exec_end_for_unified_exec( + sandbox_type: Option, session_ref: Arc, turn_ref: Arc, model_info: Arc, @@ -365,13 +368,14 @@ pub(crate) async fn emit_exec_end_for_unified_exec( duration, timed_out, }; - let event_ctx = ToolEventCtx::new( + let mut event_ctx = ToolEventCtx::new( session_ref.as_ref(), turn_ref.as_ref(), &model_info, &call_id, /*turn_diff_tracker*/ None, ); + event_ctx.sandbox_type = sandbox_type; let emitter = ToolEmitter::unified_exec( &command, cwd, @@ -392,6 +396,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( #[allow(clippy::too_many_arguments)] pub(crate) async fn emit_failed_exec_end_for_unified_exec( + sandbox_type: Option, session_ref: Arc, turn_ref: Arc, model_info: Arc, @@ -423,13 +428,14 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( duration, timed_out: false, }; - let event_ctx = ToolEventCtx::new( + let mut event_ctx = ToolEventCtx::new( session_ref.as_ref(), turn_ref.as_ref(), &model_info, &call_id, /*turn_diff_tracker*/ None, ); + event_ctx.sandbox_type = sandbox_type; let emitter = ToolEmitter::unified_exec( &command, cwd, diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 27e21f716b..ecdea4d68b 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -97,7 +97,7 @@ pub(crate) struct UnifiedExecProcess { state_tx: watch::Sender, state_rx: watch::Receiver, output_task: Option>, - sandbox_type: SandboxType, + sandbox_type: Option, timed_out: AtomicBool, _spawn_lifecycle: Option, // The shell may still need to replay this file after process startup returns. @@ -117,7 +117,7 @@ impl std::fmt::Debug for UnifiedExecProcess { impl UnifiedExecProcess { fn new( process_handle: ProcessHandle, - sandbox_type: SandboxType, + sandbox_type: Option, spawn_lifecycle: Option, ) -> Self { let output = OutputHandles { @@ -281,7 +281,7 @@ impl UnifiedExecProcess { guard.to_bytes() } - pub(crate) fn sandbox_type(&self) -> SandboxType { + pub(crate) fn sandbox_type(&self) -> Option { self.sandbox_type } @@ -309,7 +309,7 @@ impl UnifiedExecProcess { text: &str, ) -> Result<(), UnifiedExecError> { let executor_reported_denial = self.state_rx.borrow().sandbox_denied; - let sandbox_type = self.sandbox_type(); + let sandbox_type = self.sandbox_type().unwrap_or(SandboxType::None); if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) { return Ok(()); } @@ -354,7 +354,7 @@ impl UnifiedExecProcess { let output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx); let mut managed = Self::new( ProcessHandle::Local(Box::new(process_handle)), - sandbox_type, + Some(sandbox_type), Some(spawn_lifecycle), ); managed.output_task = Some(Self::spawn_local_output_task( @@ -403,7 +403,7 @@ impl UnifiedExecProcess { let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); // Older peers do not report this field. In that case, skip local // classification rather than attributing a violation to a guessed backend. - let sandbox_type = started.sandbox_type.unwrap_or(SandboxType::None); + let sandbox_type = started.sandbox_type; let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); let output_handles = managed.output_handles().clone(); managed.output_task = Some(Self::spawn_exec_server_output_task( diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a21dec6485..cb50158963 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -394,6 +394,7 @@ fn fail_process_with_message(process: &UnifiedExecProcess, message: String) -> U #[allow(clippy::too_many_arguments)] async fn emit_failed_initial_exec_end_if_unstored( process_started_alive: bool, + sandbox_type: Option, context: &UnifiedExecContext, request: &ExecCommandRequest, cwd: PathUri, @@ -408,6 +409,7 @@ async fn emit_failed_initial_exec_end_if_unstored( } emit_failed_exec_end_for_unified_exec( + sandbox_type, Arc::clone(&context.session), Arc::clone(&context.step_context.turn), Arc::clone(&context.step_context.settings.model_info), @@ -677,6 +679,7 @@ impl UnifiedExecProcessManager { .await; emit_failed_initial_exec_end_if_unstored( process_started_alive, + process.sandbox_type(), context, &request, cwd.clone(), @@ -698,6 +701,7 @@ impl UnifiedExecProcessManager { .await; emit_failed_initial_exec_end_if_unstored( process_started_alive, + process.sandbox_type(), context, &request, cwd.clone(), @@ -776,6 +780,7 @@ impl UnifiedExecProcessManager { if let Err(message) = finish_result { emit_failed_initial_exec_end_if_unstored( process_started_alive, + process.sandbox_type(), context, &request, cwd.clone(), @@ -795,6 +800,7 @@ impl UnifiedExecProcessManager { .finish_plugin_metrics(context, exit) .await; emit_exec_end_for_unified_exec( + process.sandbox_type(), Arc::clone(&context.session), Arc::clone(&context.step_context.turn), Arc::clone(&context.step_context.settings.model_info), diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 297c84fa76..74cfeb4592 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -482,6 +482,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { emit_failed_initial_exec_end_if_unstored( /*process_started_alive*/ false, + Some(codex_protocol::sandbox::SandboxType::WindowsMxc), &context, &request, #[allow(deprecated)] @@ -505,6 +506,10 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { panic!("expected CommandExecution item"); }; assert_eq!(item.id, "call-unified-denied"); + assert_eq!( + item.sandbox_type, + Some(codex_protocol::sandbox::SandboxType::WindowsMxc) + ); assert_eq!( item.status, codex_protocol::items::CommandExecutionStatus::Failed diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index e7f99e38ee..ed59dfd486 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -88,7 +88,7 @@ impl ExecProcess for MockExecProcess { pub(super) async fn remote_process( write_status: WriteStatus, terminate_error: Option, - sandbox_type: codex_sandboxing::SandboxType, + sandbox_type: impl Into>, ) -> UnifiedExecProcess { let (wake_tx, _wake_rx) = watch::channel(0); let started = StartedExecProcess { @@ -101,7 +101,7 @@ pub(super) async fn remote_process( terminate_error, wake_tx, }), - sandbox_type: Some(sandbox_type), + sandbox_type: sandbox_type.into(), }; UnifiedExecProcess::from_exec_server_started(started) @@ -198,15 +198,18 @@ async fn remote_terminate_confirmed_updates_state_on_success_only() { #[tokio::test] async fn remote_process_preserves_executor_sandbox_type() { - let process = remote_process( - WriteStatus::Accepted, - /*terminate_error*/ None, - codex_sandboxing::SandboxType::LinuxSeccomp, - ) - .await; - - assert_eq!( - process.sandbox_type(), - codex_sandboxing::SandboxType::LinuxSeccomp - ); + use codex_sandboxing::SandboxType; + for sandbox_type in [ + None, + Some(SandboxType::None), + Some(SandboxType::LinuxSeccomp), + ] { + let process = remote_process( + WriteStatus::Accepted, + /*terminate_error*/ None, + sandbox_type, + ) + .await; + assert_eq!(process.sandbox_type(), sandbox_type); + } } 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 ca49011939..15a0015aa4 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -170,6 +170,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let command_item = ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), @@ -213,6 +214,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { ItemCompletedNotification { item: ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), @@ -1415,6 +1417,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { item: ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), @@ -1458,6 +1461,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index ccdf2f2055..0ba13300f8 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -242,6 +242,11 @@ pub struct ModelInvocationContext { #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct CommandExecutionItem { + /// Observed process backend for live analytics; None means unknown or not launched. + #[serde(skip)] + #[schemars(skip)] + #[ts(skip)] + pub sandbox_type: Option, #[serde(skip)] #[schemars(skip)] #[ts(skip)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 397e2d078b..a025b0bd29 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -5581,6 +5581,7 @@ mod tests { started_at_ms: 10, item: TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: None, id: "exec-1".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), @@ -5608,6 +5609,7 @@ mod tests { completed_at_ms: 20, item: TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: None, id: "exec-1".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), diff --git a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs index ee0b5e82d8..ce2e0691f6 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs @@ -268,6 +268,7 @@ pub(super) fn completed_item( EventMsg::ExecCommandEnd(event) => Some(( TurnItem::CommandExecution(CommandExecutionItem { model_context: None, + sandbox_type: None, id: event.call_id.clone(), plugin_id: event.plugin_id.clone(), script_path: event.script_path.clone(), diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs index 35d81abfe2..395e5fb103 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -11,6 +11,7 @@ fn agent_status_uses_bounded_buffered_activity() { ItemCompletedNotification { item: ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "command-1".to_string(), command: "cargo test -p codex-tui".to_string(), cwd: AbsolutePathBuf::try_from("/workspace") diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index fdd27a414e..2adf454870 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -212,6 +212,7 @@ mod tests { fn command_execution(call_id: &str) -> ThreadItem { ThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: call_id.to_string(), command: "true".to_string(), cwd: AbsolutePathBuf::current_dir().expect("current dir").into(), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index c96ce55ca4..9779268c92 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1132,6 +1132,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { started_at_ms: 0, item: AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "cmd-1".to_string(), command: command.clone(), cwd: test_path_buf("/tmp").abs().into(), @@ -1157,6 +1158,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { completed_at_ms: 0, item: AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "cmd-1".to_string(), command, cwd: test_path_buf("/tmp").abs().into(), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index efa06b930a..9e6c59c76e 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -240,6 +240,7 @@ async fn adjacent_exploration_groups_across_reasoning_live_and_replayed() { let command = vec!["bash".to_string(), "-lc".to_string(), script.to_string()]; let mut item = AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: chat.config.cwd.clone().into(), @@ -334,6 +335,7 @@ async fn replayed_commands_preserve_individual_output_and_failure_status() { let replayed_command = |id: &str, output: &str, source: ExecCommandSource| AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: id.to_string(), command: format!("printf {output}"), cwd: cwd.clone().into(), @@ -783,6 +785,7 @@ async fn exec_end_without_begin_uses_event_command() { &mut chat, AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "call-orphan".to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: cwd.into(), diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 0414e60e70..0584d97daf 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -968,6 +968,7 @@ pub(super) fn begin_exec_with_source( .collect(); let item = AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: chat.config.cwd.clone().into(), @@ -994,6 +995,7 @@ pub(super) fn begin_unified_exec_startup( let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; let item = AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: chat.config.cwd.clone().into(), @@ -1229,6 +1231,7 @@ pub(super) fn end_exec( chat, AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id, command, cwd, diff --git a/codex-rs/tui/src/chatwidget/tests/history_projection.rs b/codex-rs/tui/src/chatwidget/tests/history_projection.rs index 2a63c7b260..30674a7167 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_projection.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_projection.rs @@ -17,6 +17,7 @@ async fn older_tool_projection_matches_initial_replay() { plugin_id: None, script_path: None, model_context: None, + sandbox_type: None, command: command.to_string(), cwd: chat.config.cwd.clone().into(), process_id: None, @@ -42,6 +43,7 @@ async fn older_tool_projection_matches_initial_replay() { plugin_id: None, script_path: None, model_context: None, + sandbox_type: None, command: command.clone(), cwd: chat.config.cwd.clone().into(), process_id: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 1c2308a4d1..c347778c09 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -5678,6 +5678,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { &mut chat, AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: cwd.clone().into(), @@ -5696,6 +5697,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { &mut chat, AppServerThreadItem::CommandExecution { model_context: None, + sandbox_type: None, id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: cwd.into(), diff --git a/codex-rs/tui/src/thread_transcript/tools_tests.rs b/codex-rs/tui/src/thread_transcript/tools_tests.rs index 42c361685d..267168c876 100644 --- a/codex-rs/tui/src/thread_transcript/tools_tests.rs +++ b/codex-rs/tui/src/thread_transcript/tools_tests.rs @@ -15,6 +15,7 @@ fn command_item(status: CommandExecutionStatus) -> ThreadItem { plugin_id: None, script_path: None, model_context: None, + sandbox_type: None, command: "cargo check".to_string(), cwd: LegacyAppPathString::from_string("/tmp/project"), process_id: None,